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.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -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 +398 -67
- 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 +714 -105
- 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/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- 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 +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- 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/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- 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 +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- 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/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -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 +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- 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 +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -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
|
@@ -1,29 +1,28 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getLsString } from '../../src/caps/capabilities';
|
|
3
|
+
import { type CertIdentity, certIdentityFromSubject } from '../../src/certfp';
|
|
3
4
|
import { capReducer } from '../../src/commands/cap';
|
|
4
5
|
import { authenticateReducer } from '../../src/commands/sasl';
|
|
6
|
+
import type { HashedAccountCredential } from '../../src/credential-hashing';
|
|
5
7
|
import { Effect } from '../../src/effects';
|
|
6
8
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
7
|
-
import type {
|
|
8
|
-
AccountStore,
|
|
9
|
-
AwayStore,
|
|
10
|
-
MtlsIdentityProvider,
|
|
11
|
-
SaslPayload,
|
|
12
|
-
SaslResult,
|
|
13
|
-
ServicesStore,
|
|
14
|
-
} from '../../src/ports';
|
|
9
|
+
import type { AwayStore, MtlsIdentityProvider, ServicesStore } from '../../src/ports';
|
|
15
10
|
import {
|
|
16
11
|
EmptyMotdProvider,
|
|
17
12
|
FakeClock,
|
|
18
13
|
InMemoryAwayStore,
|
|
19
14
|
InMemoryServicesStore,
|
|
20
15
|
SequentialIdFactory,
|
|
16
|
+
resetVerifyNickTimingSpy,
|
|
17
|
+
setVerifyNickHashedPassword,
|
|
21
18
|
} from '../../src/ports';
|
|
22
19
|
import { encodeBase64 } from '../../src/protocol/base64';
|
|
23
20
|
import type { IrcMessage } from '../../src/protocol/messages';
|
|
24
21
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
25
22
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
26
23
|
|
|
24
|
+
type VerifyHashedPasswordFn = (password: string, entry: HashedAccountCredential) => boolean;
|
|
25
|
+
|
|
27
26
|
const serverConfig: ServerConfig = {
|
|
28
27
|
serverName: 'irc.example.com',
|
|
29
28
|
networkName: 'ExampleNet',
|
|
@@ -36,6 +35,17 @@ const serverConfig: ServerConfig = {
|
|
|
36
35
|
quitMessage: 'Client Quit',
|
|
37
36
|
};
|
|
38
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Config with SASL EXTERNAL opted in — mirrors an operator setting the
|
|
40
|
+
* adapters' `EXTERNAL_ENABLED` env var. The mTLS-backed EXTERNAL tests
|
|
41
|
+
* bind this so they exercise the enabled path; the opt-in/secure-connection
|
|
42
|
+
* gate tests build their own configs explicitly.
|
|
43
|
+
*/
|
|
44
|
+
const externalServerConfig: ServerConfig = {
|
|
45
|
+
...serverConfig,
|
|
46
|
+
sasl: { externalEnabled: true },
|
|
47
|
+
};
|
|
48
|
+
|
|
39
49
|
const L = (text: string): RawLine => ({ text });
|
|
40
50
|
|
|
41
51
|
const SV = 'irc.example.com';
|
|
@@ -50,49 +60,62 @@ function saslReadyState(): ConnectionState {
|
|
|
50
60
|
s.registration = 'registering';
|
|
51
61
|
s.capNegotiating = true;
|
|
52
62
|
s.caps.add('sasl');
|
|
63
|
+
// SASL EXTERNAL requires a secure (TLS) transport; the standard helper
|
|
64
|
+
// models the common secure client so the EXTERNAL describe inherits it.
|
|
65
|
+
// The insecure-connection gate has its own explicit test.
|
|
66
|
+
s.secure = true;
|
|
53
67
|
return s;
|
|
54
68
|
}
|
|
55
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Builds a {@link InMemoryServicesStore} with `nick` registered under
|
|
72
|
+
* `password`. The single credential home for SASL PLAIN / EXTERNAL / PASS.
|
|
73
|
+
*/
|
|
74
|
+
function registeredServices(
|
|
75
|
+
nick = 'alice',
|
|
76
|
+
password = 'secret',
|
|
77
|
+
email = 'alice@example.com',
|
|
78
|
+
): InMemoryServicesStore {
|
|
79
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
80
|
+
services.registerNick(nick, password, email);
|
|
81
|
+
return services;
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
function makeCtx(
|
|
57
85
|
state: ConnectionState,
|
|
58
|
-
accounts?: AccountStore,
|
|
59
|
-
mtlsIdentity?: MtlsIdentityProvider,
|
|
60
86
|
services?: ServicesStore,
|
|
87
|
+
mtlsIdentity?: MtlsIdentityProvider,
|
|
61
88
|
away?: AwayStore,
|
|
62
89
|
): Ctx {
|
|
63
90
|
return buildCtx({
|
|
64
|
-
|
|
91
|
+
// A test that binds an mTLS provider intends to exercise the EXTERNAL
|
|
92
|
+
// path, so it gets the EXTERNAL-enabled config (mirrors an operator
|
|
93
|
+
// setting EXTERNAL_ENABLED). The gate matrix tests below build their
|
|
94
|
+
// own ctxs to control the flag explicitly.
|
|
95
|
+
serverConfig: mtlsIdentity !== undefined ? externalServerConfig : serverConfig,
|
|
65
96
|
clock: new FakeClock(1_000),
|
|
66
97
|
ids: new SequentialIdFactory(),
|
|
67
98
|
motd: EmptyMotdProvider,
|
|
68
99
|
connection: state,
|
|
69
|
-
...(accounts !== undefined ? { accounts } : {}),
|
|
70
|
-
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
71
100
|
...(services !== undefined ? { services } : {}),
|
|
101
|
+
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
72
102
|
...(away !== undefined ? { away } : {}),
|
|
73
103
|
});
|
|
74
104
|
}
|
|
75
105
|
|
|
76
|
-
/** A capture-and-respond fake AccountStore. */
|
|
77
|
-
class FakeAccountStore implements AccountStore {
|
|
78
|
-
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
79
|
-
constructor(private readonly result: SaslResult) {}
|
|
80
|
-
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
81
|
-
this.calls.push({ mech, payload });
|
|
82
|
-
return this.result;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
106
|
/** A fake MtlsIdentityProvider that returns a canned identity for one connId. */
|
|
87
107
|
class FakeMtlsIdentityProvider implements MtlsIdentityProvider {
|
|
88
108
|
readonly calls: string[] = [];
|
|
89
109
|
constructor(
|
|
90
110
|
private readonly connId: string,
|
|
91
|
-
private readonly
|
|
111
|
+
private readonly subject: string | undefined,
|
|
112
|
+
private readonly fingerprint?: string,
|
|
92
113
|
) {}
|
|
93
|
-
getIdentity(connId: string):
|
|
114
|
+
getIdentity(connId: string): CertIdentity | undefined {
|
|
94
115
|
this.calls.push(connId);
|
|
95
|
-
return connId === this.connId
|
|
116
|
+
return connId === this.connId && this.subject !== undefined
|
|
117
|
+
? certIdentityFromSubject(this.subject, this.fingerprint)
|
|
118
|
+
: undefined;
|
|
96
119
|
}
|
|
97
120
|
}
|
|
98
121
|
|
|
@@ -135,10 +158,10 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
135
158
|
expect(state.saslMech).toBe('PLAIN');
|
|
136
159
|
});
|
|
137
160
|
|
|
138
|
-
it('completes with 900 and 903 when the
|
|
161
|
+
it('completes with 900 and 903 when the ServicesStore accepts the credentials', () => {
|
|
139
162
|
const state = saslReadyState();
|
|
140
|
-
const
|
|
141
|
-
const ctx = makeCtx(state,
|
|
163
|
+
const services = registeredServices('alice', 'secret');
|
|
164
|
+
const ctx = makeCtx(state, services);
|
|
142
165
|
|
|
143
166
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
144
167
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -152,8 +175,8 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
152
175
|
|
|
153
176
|
it('records the authenticated account name on the connection', () => {
|
|
154
177
|
const state = saslReadyState();
|
|
155
|
-
const
|
|
156
|
-
const ctx = makeCtx(state,
|
|
178
|
+
const services = registeredServices('alice', 'secret');
|
|
179
|
+
const ctx = makeCtx(state, services);
|
|
157
180
|
|
|
158
181
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
159
182
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -163,11 +186,10 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
163
186
|
|
|
164
187
|
it('seeds lastReadMarkers from the ServicesStore at identify', () => {
|
|
165
188
|
const state = saslReadyState();
|
|
166
|
-
const
|
|
167
|
-
const services = new InMemoryServicesStore();
|
|
189
|
+
const services = registeredServices('alice', 'secret');
|
|
168
190
|
services.setLastReadMarker('alice', '#foo', 'm1');
|
|
169
191
|
services.setLastReadMarker('alice', '#bar', 'm2');
|
|
170
|
-
const ctx = makeCtx(state,
|
|
192
|
+
const ctx = makeCtx(state, services);
|
|
171
193
|
|
|
172
194
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
173
195
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -178,10 +200,9 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
178
200
|
|
|
179
201
|
it('does not seed markers for a different account', () => {
|
|
180
202
|
const state = saslReadyState();
|
|
181
|
-
const
|
|
182
|
-
const services = new InMemoryServicesStore();
|
|
203
|
+
const services = registeredServices('alice', 'secret');
|
|
183
204
|
services.setLastReadMarker('bob', '#foo', 'm9');
|
|
184
|
-
const ctx = makeCtx(state,
|
|
205
|
+
const ctx = makeCtx(state, services);
|
|
185
206
|
|
|
186
207
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
187
208
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -191,10 +212,10 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
191
212
|
|
|
192
213
|
it('replays the persisted away reason on identify (draft/pre-away)', () => {
|
|
193
214
|
const state = saslReadyState();
|
|
194
|
-
const
|
|
215
|
+
const services = registeredServices('alice', 'secret');
|
|
195
216
|
const away = new InMemoryAwayStore();
|
|
196
217
|
away.set('alice', 'brb');
|
|
197
|
-
const ctx = makeCtx(state,
|
|
218
|
+
const ctx = makeCtx(state, services, undefined, away);
|
|
198
219
|
|
|
199
220
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
200
221
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -204,10 +225,10 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
204
225
|
|
|
205
226
|
it('emits 306 RPL_NOWAWAY after identifying when an away reason is replayed', () => {
|
|
206
227
|
const state = saslReadyState();
|
|
207
|
-
const
|
|
228
|
+
const services = registeredServices('alice', 'secret');
|
|
208
229
|
const away = new InMemoryAwayStore();
|
|
209
230
|
away.set('alice', 'brb');
|
|
210
|
-
const ctx = makeCtx(state,
|
|
231
|
+
const ctx = makeCtx(state, services, undefined, away);
|
|
211
232
|
|
|
212
233
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
213
234
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -220,9 +241,9 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
220
241
|
|
|
221
242
|
it('does not replay an away reason when none is persisted', () => {
|
|
222
243
|
const state = saslReadyState();
|
|
223
|
-
const
|
|
244
|
+
const services = registeredServices('alice', 'secret');
|
|
224
245
|
const away = new InMemoryAwayStore();
|
|
225
|
-
const ctx = makeCtx(state,
|
|
246
|
+
const ctx = makeCtx(state, services, undefined, away);
|
|
226
247
|
|
|
227
248
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
228
249
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -234,8 +255,8 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
234
255
|
|
|
235
256
|
it('does not replay an away reason when no AwayStore is bound', () => {
|
|
236
257
|
const state = saslReadyState();
|
|
237
|
-
const
|
|
238
|
-
const ctx = makeCtx(state,
|
|
258
|
+
const services = registeredServices('alice', 'secret');
|
|
259
|
+
const ctx = makeCtx(state, services); // no away store
|
|
239
260
|
|
|
240
261
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
241
262
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -247,10 +268,10 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
247
268
|
|
|
248
269
|
it('looks up the persisted away reason case-insensitively', () => {
|
|
249
270
|
const state = saslReadyState();
|
|
250
|
-
const
|
|
271
|
+
const services = registeredServices('alice', 'secret');
|
|
251
272
|
const away = new InMemoryAwayStore();
|
|
252
273
|
away.set('ALICE', 'brb');
|
|
253
|
-
const ctx = makeCtx(state,
|
|
274
|
+
const ctx = makeCtx(state, services, undefined, away);
|
|
254
275
|
|
|
255
276
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
256
277
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -260,10 +281,9 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
260
281
|
|
|
261
282
|
it('delivers queued unread memos on a successful SASL login', () => {
|
|
262
283
|
const state = saslReadyState();
|
|
263
|
-
const
|
|
264
|
-
const services = new InMemoryServicesStore();
|
|
284
|
+
const services = registeredServices('alice', 'secret');
|
|
265
285
|
services.recordMemo({ from: 'bob', to: 'alice', body: 'Hi alice!', sentAt: 1_000 });
|
|
266
|
-
const ctx = makeCtx(state,
|
|
286
|
+
const ctx = makeCtx(state, services);
|
|
267
287
|
|
|
268
288
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
269
289
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -276,10 +296,9 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
276
296
|
|
|
277
297
|
it('marks delivered memos as read after a successful SASL login', () => {
|
|
278
298
|
const state = saslReadyState();
|
|
279
|
-
const
|
|
280
|
-
const services = new InMemoryServicesStore();
|
|
299
|
+
const services = registeredServices('alice', 'secret');
|
|
281
300
|
services.recordMemo({ from: 'bob', to: 'alice', body: 'Hi alice!', sentAt: 1_000 });
|
|
282
|
-
const ctx = makeCtx(state,
|
|
301
|
+
const ctx = makeCtx(state, services);
|
|
283
302
|
|
|
284
303
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
285
304
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -289,11 +308,10 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
289
308
|
|
|
290
309
|
it('does not deliver already-read memos on a SASL login', () => {
|
|
291
310
|
const state = saslReadyState();
|
|
292
|
-
const
|
|
293
|
-
const services = new InMemoryServicesStore();
|
|
311
|
+
const services = registeredServices('alice', 'secret');
|
|
294
312
|
services.recordMemo({ from: 'bob', to: 'alice', body: 'old', sentAt: 1_000 });
|
|
295
313
|
services.markMemoRead('alice', 1);
|
|
296
|
-
const ctx = makeCtx(state,
|
|
314
|
+
const ctx = makeCtx(state, services);
|
|
297
315
|
|
|
298
316
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
299
317
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -305,10 +323,9 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
305
323
|
it('applies the assigned HostServ vhost on SASL PLAIN success', () => {
|
|
306
324
|
const state = saslReadyState();
|
|
307
325
|
state.host = 'real.example.net';
|
|
308
|
-
const
|
|
309
|
-
const services = new InMemoryServicesStore();
|
|
326
|
+
const services = registeredServices('alice', 'secret');
|
|
310
327
|
services.setVhost('alice', 'cool.example.net');
|
|
311
|
-
const ctx = makeCtx(state,
|
|
328
|
+
const ctx = makeCtx(state, services);
|
|
312
329
|
|
|
313
330
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
314
331
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -321,10 +338,9 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
321
338
|
it('does not emit a CHGHOST broadcast on SASL success (pre-registration, no peers)', () => {
|
|
322
339
|
const state = saslReadyState();
|
|
323
340
|
state.host = 'real.example.net';
|
|
324
|
-
const
|
|
325
|
-
const services = new InMemoryServicesStore();
|
|
341
|
+
const services = registeredServices('alice', 'secret');
|
|
326
342
|
services.setVhost('alice', 'cool.example.net');
|
|
327
|
-
const ctx = makeCtx(state,
|
|
343
|
+
const ctx = makeCtx(state, services);
|
|
328
344
|
|
|
329
345
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
330
346
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -335,9 +351,8 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
335
351
|
it('leaves host unchanged on SASL success when no vhost is assigned', () => {
|
|
336
352
|
const state = saslReadyState();
|
|
337
353
|
state.host = 'real.example.net';
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
const ctx = makeCtx(state, accounts, undefined, services);
|
|
354
|
+
const services = registeredServices('alice', 'secret');
|
|
355
|
+
const ctx = makeCtx(state, services);
|
|
341
356
|
|
|
342
357
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
343
358
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -348,9 +363,8 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
348
363
|
|
|
349
364
|
it('delivers nothing on a SASL login when the account has no memos', () => {
|
|
350
365
|
const state = saslReadyState();
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
const ctx = makeCtx(state, accounts, undefined, services);
|
|
366
|
+
const services = registeredServices('alice', 'secret');
|
|
367
|
+
const ctx = makeCtx(state, services);
|
|
354
368
|
|
|
355
369
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
356
370
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -361,10 +375,9 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
361
375
|
|
|
362
376
|
it('looks up queued memos case-insensitively on a SASL login', () => {
|
|
363
377
|
const state = saslReadyState();
|
|
364
|
-
const
|
|
365
|
-
const services = new InMemoryServicesStore();
|
|
378
|
+
const services = registeredServices('alice', 'secret');
|
|
366
379
|
services.recordMemo({ from: 'bob', to: 'ALICE', body: 'folded', sentAt: 1_000 });
|
|
367
|
-
const ctx = makeCtx(state,
|
|
380
|
+
const ctx = makeCtx(state, services);
|
|
368
381
|
|
|
369
382
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
370
383
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -377,8 +390,7 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
377
390
|
|
|
378
391
|
it('does not deliver memos when no ServicesStore is bound', () => {
|
|
379
392
|
const state = saslReadyState();
|
|
380
|
-
const
|
|
381
|
-
const ctx = makeCtx(state, accounts);
|
|
393
|
+
const ctx = makeCtx(state);
|
|
382
394
|
|
|
383
395
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
384
396
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -389,8 +401,8 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
389
401
|
|
|
390
402
|
it('clears the in-progress mechanism after a successful authentication', () => {
|
|
391
403
|
const state = saslReadyState();
|
|
392
|
-
const
|
|
393
|
-
const ctx = makeCtx(state,
|
|
404
|
+
const services = registeredServices('alice', 'secret');
|
|
405
|
+
const ctx = makeCtx(state, services);
|
|
394
406
|
|
|
395
407
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
396
408
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -398,40 +410,36 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
398
410
|
expect(state.saslMech).toBeUndefined();
|
|
399
411
|
});
|
|
400
412
|
|
|
401
|
-
it('forwards parsed PLAIN credentials to
|
|
413
|
+
it('forwards parsed PLAIN credentials to services.verifyNick', () => {
|
|
402
414
|
const state = saslReadyState();
|
|
403
|
-
const
|
|
404
|
-
const
|
|
415
|
+
const services = registeredServices('alice', 'secret');
|
|
416
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
417
|
+
const ctx = makeCtx(state, services);
|
|
405
418
|
|
|
406
419
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
407
420
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
408
421
|
|
|
409
|
-
expect(
|
|
410
|
-
{ mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
|
|
411
|
-
]);
|
|
422
|
+
expect(verifyNick).toHaveBeenCalledWith('alice', 'secret');
|
|
412
423
|
});
|
|
413
424
|
|
|
414
425
|
it('ignores the authorization identity field in the PLAIN payload', () => {
|
|
415
426
|
const state = saslReadyState();
|
|
416
|
-
const
|
|
417
|
-
const
|
|
427
|
+
const services = registeredServices('alice', 'secret');
|
|
428
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
429
|
+
const ctx = makeCtx(state, services);
|
|
418
430
|
|
|
419
431
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
420
432
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret', 'bob')), ctx);
|
|
421
433
|
|
|
422
|
-
expect(
|
|
423
|
-
kind: 'PLAIN',
|
|
424
|
-
username: 'alice',
|
|
425
|
-
password: 'secret',
|
|
426
|
-
});
|
|
434
|
+
expect(verifyNick).toHaveBeenCalledWith('alice', 'secret');
|
|
427
435
|
});
|
|
428
436
|
|
|
429
437
|
it('targets `*` with an empty hostmask when the client has no nick yet', () => {
|
|
430
438
|
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
431
439
|
state.capNegotiating = true;
|
|
432
440
|
state.caps.add('sasl');
|
|
433
|
-
const
|
|
434
|
-
const ctx = makeCtx(state,
|
|
441
|
+
const services = registeredServices('alice', 'secret');
|
|
442
|
+
const ctx = makeCtx(state, services);
|
|
435
443
|
|
|
436
444
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
437
445
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -449,10 +457,10 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
449
457
|
// ============================================================================
|
|
450
458
|
|
|
451
459
|
describe('authenticateReducer — PLAIN failure', () => {
|
|
452
|
-
it('emits 904 ERR_SASLFAIL when the
|
|
460
|
+
it('emits 904 ERR_SASLFAIL when the ServicesStore rejects the credentials', () => {
|
|
453
461
|
const state = saslReadyState();
|
|
454
|
-
const
|
|
455
|
-
const ctx = makeCtx(state,
|
|
462
|
+
const services = registeredServices('alice', 'secret');
|
|
463
|
+
const ctx = makeCtx(state, services);
|
|
456
464
|
|
|
457
465
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
458
466
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
|
|
@@ -464,8 +472,8 @@ describe('authenticateReducer — PLAIN failure', () => {
|
|
|
464
472
|
|
|
465
473
|
it('does not record an account on failure', () => {
|
|
466
474
|
const state = saslReadyState();
|
|
467
|
-
const
|
|
468
|
-
const ctx = makeCtx(state,
|
|
475
|
+
const services = registeredServices('alice', 'secret');
|
|
476
|
+
const ctx = makeCtx(state, services);
|
|
469
477
|
|
|
470
478
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
471
479
|
authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
|
|
@@ -475,8 +483,8 @@ describe('authenticateReducer — PLAIN failure', () => {
|
|
|
475
483
|
|
|
476
484
|
it('clears the in-progress mechanism after a failure', () => {
|
|
477
485
|
const state = saslReadyState();
|
|
478
|
-
const
|
|
479
|
-
const ctx = makeCtx(state,
|
|
486
|
+
const services = registeredServices('alice', 'secret');
|
|
487
|
+
const ctx = makeCtx(state, services);
|
|
480
488
|
|
|
481
489
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
482
490
|
authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
|
|
@@ -486,11 +494,62 @@ describe('authenticateReducer — PLAIN failure', () => {
|
|
|
486
494
|
});
|
|
487
495
|
|
|
488
496
|
// ============================================================================
|
|
489
|
-
// AUTHENTICATE PLAIN —
|
|
497
|
+
// AUTHENTICATE PLAIN — timing equalisation (username-enumeration defence).
|
|
498
|
+
// The unknown-username and wrong-password paths must perform the SAME
|
|
499
|
+
// number of scrypt verifies (one each) and emit the identical 904 line, so
|
|
500
|
+
// a remote attacker cannot distinguish "user exists, wrong password" from
|
|
501
|
+
// "user unknown" by measuring latency between AUTHENTICATE and 904.
|
|
502
|
+
// ============================================================================
|
|
503
|
+
|
|
504
|
+
describe('authenticateReducer — PLAIN timing equalisation (username enumeration)', () => {
|
|
505
|
+
afterEach(() => {
|
|
506
|
+
resetVerifyNickTimingSpy();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
function runPlainFailure(
|
|
510
|
+
authcid: string,
|
|
511
|
+
password: string,
|
|
512
|
+
): { verifyCalls: number; line: string } {
|
|
513
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
514
|
+
setVerifyNickHashedPassword(spy);
|
|
515
|
+
let out: { verifyCalls: number; line: string };
|
|
516
|
+
try {
|
|
517
|
+
const state = saslReadyState();
|
|
518
|
+
const ctx = makeCtx(state, registeredServices('alice', 'secret'));
|
|
519
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
520
|
+
const result = authenticateReducer(state, authenticate(plainPayload(authcid, password)), ctx);
|
|
521
|
+
out = { verifyCalls: spy.mock.calls.length, line: firstLineText(result.effects[0]) };
|
|
522
|
+
} finally {
|
|
523
|
+
setVerifyNickHashedPassword(null);
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
it('runs exactly one scrypt verify on the unknown-username path (no O(1) miss)', () => {
|
|
529
|
+
const { verifyCalls } = runPlainFailure('definitely-not-a-user', 'secret');
|
|
530
|
+
expect(verifyCalls).toBe(1);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('runs exactly one scrypt verify on the wrong-password path', () => {
|
|
534
|
+
const { verifyCalls } = runPlainFailure('alice', 'wrong');
|
|
535
|
+
expect(verifyCalls).toBe(1);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('emits the identical 904 ERR_SASLFAIL on both paths with equal verify cost', () => {
|
|
539
|
+
const unknownUser = runPlainFailure('definitely-not-a-user', 'secret');
|
|
540
|
+
const wrongPassword = runPlainFailure('alice', 'wrong');
|
|
541
|
+
expect(unknownUser.verifyCalls).toBe(wrongPassword.verifyCalls);
|
|
542
|
+
expect(unknownUser.line).toBe(`:${SV} 904 alice :SASL authentication failed`);
|
|
543
|
+
expect(wrongPassword.line).toBe(unknownUser.line);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// ============================================================================
|
|
548
|
+
// AUTHENTICATE PLAIN — no ServicesStore wired
|
|
490
549
|
// ============================================================================
|
|
491
550
|
|
|
492
|
-
describe('authenticateReducer — no
|
|
493
|
-
it('emits 904 when the PLAIN payload arrives but no
|
|
551
|
+
describe('authenticateReducer — no ServicesStore configured', () => {
|
|
552
|
+
it('emits 904 when the PLAIN payload arrives but no ServicesStore is wired', () => {
|
|
494
553
|
const state = saslReadyState();
|
|
495
554
|
const ctx = makeCtx(state);
|
|
496
555
|
|
|
@@ -531,17 +590,63 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
|
531
590
|
|
|
532
591
|
it('succeeds (900/903) when a valid cert identity is presented', () => {
|
|
533
592
|
const state = saslReadyState();
|
|
534
|
-
const
|
|
593
|
+
const services = registeredServices('alice', 'secret');
|
|
594
|
+
services.addCertFP('alice', 'CN=alice');
|
|
535
595
|
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
536
|
-
const ctx = makeCtx(state,
|
|
596
|
+
const ctx = makeCtx(state, services, provider);
|
|
537
597
|
|
|
538
598
|
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
539
599
|
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
540
600
|
|
|
541
601
|
expect(state.account).toBe('alice');
|
|
542
|
-
expect(
|
|
543
|
-
{
|
|
602
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
603
|
+
L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
|
|
604
|
+
L(`:${SV} 903 alice :SASL authentication successful`),
|
|
544
605
|
]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('forwards the resolved mTLS identity to services.verifyCertFP', () => {
|
|
609
|
+
const state = saslReadyState();
|
|
610
|
+
const services = registeredServices('alice', 'secret');
|
|
611
|
+
services.addCertFP('alice', 'CN=alice');
|
|
612
|
+
const verifyCertFP = vi.spyOn(services, 'verifyCertFP');
|
|
613
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
614
|
+
const ctx = makeCtx(state, services, provider);
|
|
615
|
+
|
|
616
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
617
|
+
authenticateReducer(state, authenticate('+'), ctx);
|
|
618
|
+
|
|
619
|
+
expect(verifyCertFP).toHaveBeenCalledWith(certIdentityFromSubject('CN=alice'));
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('forwards the platform fingerprint on the identity to verifyCertFP', () => {
|
|
623
|
+
const state = saslReadyState();
|
|
624
|
+
const services = registeredServices('alice', 'secret');
|
|
625
|
+
services.addCertFingerprint('alice', 'aa112233');
|
|
626
|
+
const verifyCertFP = vi.spyOn(services, 'verifyCertFP');
|
|
627
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice', 'aa112233');
|
|
628
|
+
const ctx = makeCtx(state, services, provider);
|
|
629
|
+
|
|
630
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
631
|
+
authenticateReducer(state, authenticate('+'), ctx);
|
|
632
|
+
|
|
633
|
+
expect(verifyCertFP).toHaveBeenCalledWith(
|
|
634
|
+
expect.objectContaining({ subject: 'CN=alice', fingerprint: 'aa112233' }),
|
|
635
|
+
);
|
|
636
|
+
expect(state.account).toBe('alice');
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('succeeds via an equivalent DN spelling (canonical compare)', () => {
|
|
640
|
+
const state = saslReadyState();
|
|
641
|
+
const services = registeredServices('alice', 'secret');
|
|
642
|
+
services.addCertFP('alice', 'CN=alice , O = Example Inc');
|
|
643
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'O=Example Inc,CN=alice');
|
|
644
|
+
const ctx = makeCtx(state, services, provider);
|
|
645
|
+
|
|
646
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
647
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
648
|
+
|
|
649
|
+
expect(state.account).toBe('alice');
|
|
545
650
|
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
546
651
|
L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
|
|
547
652
|
L(`:${SV} 903 alice :SASL authentication successful`),
|
|
@@ -550,9 +655,10 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
|
550
655
|
|
|
551
656
|
it('clears saslMech/saslBuffer after a successful EXTERNAL', () => {
|
|
552
657
|
const state = saslReadyState();
|
|
553
|
-
const
|
|
658
|
+
const services = registeredServices('alice', 'secret');
|
|
659
|
+
services.addCertFP('alice', 'CN=alice');
|
|
554
660
|
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
555
|
-
const ctx = makeCtx(state,
|
|
661
|
+
const ctx = makeCtx(state, services, provider);
|
|
556
662
|
|
|
557
663
|
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
558
664
|
authenticateReducer(state, authenticate('+'), ctx);
|
|
@@ -563,11 +669,11 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
|
563
669
|
|
|
564
670
|
it('delivers queued memos on a successful EXTERNAL login', () => {
|
|
565
671
|
const state = saslReadyState();
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
const services = new InMemoryServicesStore();
|
|
672
|
+
const services = registeredServices('alice', 'secret');
|
|
673
|
+
services.addCertFP('alice', 'CN=alice');
|
|
569
674
|
services.recordMemo({ from: 'bob', to: 'alice', body: 'via mTLS', sentAt: 1_000 });
|
|
570
|
-
const
|
|
675
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
676
|
+
const ctx = makeCtx(state, services, provider);
|
|
571
677
|
|
|
572
678
|
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
573
679
|
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
@@ -581,9 +687,26 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
|
581
687
|
|
|
582
688
|
it('fails with 904 when the provider has no identity for the connection', () => {
|
|
583
689
|
const state = saslReadyState();
|
|
584
|
-
const
|
|
690
|
+
const services = registeredServices('alice', 'secret');
|
|
691
|
+
services.addCertFP('alice', 'CN=alice');
|
|
585
692
|
const provider = new FakeMtlsIdentityProvider('c1', undefined);
|
|
586
|
-
const ctx = makeCtx(state,
|
|
693
|
+
const ctx = makeCtx(state, services, provider);
|
|
694
|
+
|
|
695
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
696
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
697
|
+
|
|
698
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
699
|
+
L(`:${SV} 904 alice :SASL authentication failed`),
|
|
700
|
+
]);
|
|
701
|
+
expect(state.account).toBeUndefined();
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('fails with 904 when the subject is not attached to any account', () => {
|
|
705
|
+
const state = saslReadyState();
|
|
706
|
+
const services = registeredServices('alice', 'secret');
|
|
707
|
+
// No cert subject attached to alice; CN=evil is unknown to the store.
|
|
708
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=evil');
|
|
709
|
+
const ctx = makeCtx(state, services, provider);
|
|
587
710
|
|
|
588
711
|
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
589
712
|
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
@@ -594,11 +717,12 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
|
594
717
|
expect(state.account).toBeUndefined();
|
|
595
718
|
});
|
|
596
719
|
|
|
597
|
-
it('
|
|
720
|
+
it('does not leak account existence on an unknown subject (no state change)', () => {
|
|
598
721
|
const state = saslReadyState();
|
|
599
|
-
const
|
|
722
|
+
const services = registeredServices('alice', 'secret');
|
|
723
|
+
services.addCertFP('alice', 'CN=alice');
|
|
600
724
|
const provider = new FakeMtlsIdentityProvider('c1', 'CN=evil');
|
|
601
|
-
const ctx = makeCtx(state,
|
|
725
|
+
const ctx = makeCtx(state, services, provider);
|
|
602
726
|
|
|
603
727
|
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
604
728
|
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
@@ -606,9 +730,11 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
|
606
730
|
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
607
731
|
L(`:${SV} 904 alice :SASL authentication failed`),
|
|
608
732
|
]);
|
|
733
|
+
expect(state.account).toBeUndefined();
|
|
734
|
+
expect(state.saslMech).toBeUndefined();
|
|
609
735
|
});
|
|
610
736
|
|
|
611
|
-
it('fails with 904 when no
|
|
737
|
+
it('fails with 904 when no ServicesStore is configured', () => {
|
|
612
738
|
const state = saslReadyState();
|
|
613
739
|
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
614
740
|
const ctx = makeCtx(state, undefined, provider);
|
|
@@ -622,6 +748,223 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
|
622
748
|
});
|
|
623
749
|
});
|
|
624
750
|
|
|
751
|
+
// ============================================================================
|
|
752
|
+
// AUTHENTICATE EXTERNAL — operator opt-in + secure-connection gate
|
|
753
|
+
// ============================================================================
|
|
754
|
+
|
|
755
|
+
describe('authenticateReducer — EXTERNAL opt-in / secure-connection gate', () => {
|
|
756
|
+
/** Builds a ctx with full control over the sasl config + transport. */
|
|
757
|
+
function gateCtx(
|
|
758
|
+
state: ConnectionState,
|
|
759
|
+
opts: { externalEnabled: boolean; provider?: MtlsIdentityProvider },
|
|
760
|
+
): Ctx {
|
|
761
|
+
return buildCtx({
|
|
762
|
+
serverConfig: { ...serverConfig, sasl: { externalEnabled: opts.externalEnabled } },
|
|
763
|
+
clock: new FakeClock(1_000),
|
|
764
|
+
ids: new SequentialIdFactory(),
|
|
765
|
+
motd: EmptyMotdProvider,
|
|
766
|
+
connection: state,
|
|
767
|
+
...(opts.provider !== undefined ? { mtlsIdentity: opts.provider } : {}),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
it('refuses EXTERNAL with 908 when the flag is unset, even with mTLS on a secure connection', () => {
|
|
772
|
+
const state = saslReadyState();
|
|
773
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
774
|
+
const ctx = gateCtx(state, { externalEnabled: false, provider });
|
|
775
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
776
|
+
|
|
777
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
778
|
+
L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
|
|
779
|
+
]);
|
|
780
|
+
expect(state.saslMech).toBeUndefined();
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('refuses EXTERNAL with 908 when the sasl block omits the flag entirely', () => {
|
|
784
|
+
// A deployment may set other sasl knobs (lockout tuning) without the
|
|
785
|
+
// EXTERNAL opt-in; the flag defaults off — absence is not consent.
|
|
786
|
+
const state = saslReadyState();
|
|
787
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
788
|
+
const ctx = buildCtx({
|
|
789
|
+
serverConfig: { ...serverConfig, sasl: { maxFailures: 3 } },
|
|
790
|
+
clock: new FakeClock(1_000),
|
|
791
|
+
ids: new SequentialIdFactory(),
|
|
792
|
+
motd: EmptyMotdProvider,
|
|
793
|
+
connection: state,
|
|
794
|
+
mtlsIdentity: provider,
|
|
795
|
+
});
|
|
796
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
797
|
+
|
|
798
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
799
|
+
L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
|
|
800
|
+
]);
|
|
801
|
+
expect(state.saslMech).toBeUndefined();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('refuses EXTERNAL with 908 on an insecure connection even when the flag is enabled', () => {
|
|
805
|
+
const state = saslReadyState();
|
|
806
|
+
state.secure = false;
|
|
807
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
808
|
+
const ctx = gateCtx(state, { externalEnabled: true, provider });
|
|
809
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
810
|
+
|
|
811
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
812
|
+
L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
|
|
813
|
+
]);
|
|
814
|
+
expect(state.saslMech).toBeUndefined();
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('does not count the 908 opt-in / secure-connection refusal toward the lockout', () => {
|
|
818
|
+
// A protocol-level mechanism rejection is not a credential attempt;
|
|
819
|
+
// an attacker probing EXTERNAL on an insecure transport must not be
|
|
820
|
+
// able to burn the failure budget (nor succeed by accident).
|
|
821
|
+
const state = saslReadyState();
|
|
822
|
+
state.secure = false;
|
|
823
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
824
|
+
const ctx = gateCtx(state, { externalEnabled: true, provider });
|
|
825
|
+
|
|
826
|
+
for (let i = 0; i < 3; i++) {
|
|
827
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
828
|
+
expect(firstLineText(out.effects[0])).toContain(' 908 ');
|
|
829
|
+
}
|
|
830
|
+
expect(state.saslFailures).toBeUndefined();
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('enters the payload phase when the flag is enabled on a secure connection with mTLS', () => {
|
|
834
|
+
const state = saslReadyState();
|
|
835
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
836
|
+
const ctx = gateCtx(state, { externalEnabled: true, provider });
|
|
837
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
838
|
+
|
|
839
|
+
expect(state.saslMech).toBe('EXTERNAL');
|
|
840
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([L('AUTHENTICATE +')]);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('succeeds (900/903) with a bound cert on a secure, EXTERNAL-enabled connection', () => {
|
|
844
|
+
const state = saslReadyState();
|
|
845
|
+
const services = registeredServices('alice', 'secret');
|
|
846
|
+
services.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
|
|
847
|
+
const provider = new FakeMtlsIdentityProvider(
|
|
848
|
+
'c1',
|
|
849
|
+
'CN=alice',
|
|
850
|
+
'AA:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF',
|
|
851
|
+
);
|
|
852
|
+
const ctx = buildCtx({
|
|
853
|
+
serverConfig: externalServerConfig,
|
|
854
|
+
clock: new FakeClock(1_000),
|
|
855
|
+
ids: new SequentialIdFactory(),
|
|
856
|
+
motd: EmptyMotdProvider,
|
|
857
|
+
connection: state,
|
|
858
|
+
services,
|
|
859
|
+
mtlsIdentity: provider,
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
863
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
864
|
+
|
|
865
|
+
expect(state.account).toBe('alice');
|
|
866
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
867
|
+
L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
|
|
868
|
+
L(`:${SV} 903 alice :SASL authentication successful`),
|
|
869
|
+
]);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('fails with 904 on a secure, EXTERNAL-enabled connection when no binding matches the cert', () => {
|
|
873
|
+
const state = saslReadyState();
|
|
874
|
+
const services = registeredServices('alice', 'secret');
|
|
875
|
+
// alice has no cert binding; the presented cert is unknown to the store.
|
|
876
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=intruder');
|
|
877
|
+
const ctx = buildCtx({
|
|
878
|
+
serverConfig: externalServerConfig,
|
|
879
|
+
clock: new FakeClock(1_000),
|
|
880
|
+
ids: new SequentialIdFactory(),
|
|
881
|
+
motd: EmptyMotdProvider,
|
|
882
|
+
connection: state,
|
|
883
|
+
services,
|
|
884
|
+
mtlsIdentity: provider,
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
888
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
889
|
+
|
|
890
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
891
|
+
L(`:${SV} 904 alice :SASL authentication failed`),
|
|
892
|
+
]);
|
|
893
|
+
expect(state.account).toBeUndefined();
|
|
894
|
+
expect(state.saslFailures).toEqual([1_000]);
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// ============================================================================
|
|
899
|
+
// AUTHENTICATE EXTERNAL — transport-level non-support override
|
|
900
|
+
// ============================================================================
|
|
901
|
+
|
|
902
|
+
describe('authenticateReducer — EXTERNAL transport-level non-support override', () => {
|
|
903
|
+
/**
|
|
904
|
+
* Builds a ctx carrying the transport-level EXTERNAL non-support
|
|
905
|
+
* override — the seam a transport with no mTLS identity source (the
|
|
906
|
+
* Spectrum TCP container origin) uses to reject
|
|
907
|
+
* `AUTHENTICATE EXTERNAL` with an explicit, stable `904` instead of
|
|
908
|
+
* the generic three-way-gate `908`.
|
|
909
|
+
*/
|
|
910
|
+
function unsupportedCtx(state: ConnectionState): Ctx {
|
|
911
|
+
return buildCtx({
|
|
912
|
+
serverConfig: {
|
|
913
|
+
...serverConfig,
|
|
914
|
+
sasl: {
|
|
915
|
+
externalEnabled: true,
|
|
916
|
+
externalUnsupportedMessage: 'SASL EXTERNAL not supported on this transport',
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
clock: new FakeClock(1_000),
|
|
920
|
+
ids: new SequentialIdFactory(),
|
|
921
|
+
motd: EmptyMotdProvider,
|
|
922
|
+
connection: state,
|
|
923
|
+
mtlsIdentity: new FakeMtlsIdentityProvider('c1', 'CN=alice'),
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
it('rejects EXTERNAL with the configured 904 message even when the three-way gate holds', () => {
|
|
928
|
+
// The override is authoritative: a transport that cannot surface a
|
|
929
|
+
// client-cert identity must reject up front with the transport-
|
|
930
|
+
// specific message, never fall through to the generic refusal —
|
|
931
|
+
// even if every gate input (mTLS source, opt-in, secure) is bound.
|
|
932
|
+
const state = saslReadyState();
|
|
933
|
+
const ctx = unsupportedCtx(state);
|
|
934
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
935
|
+
|
|
936
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
937
|
+
L(`:${SV} 904 alice :SASL EXTERNAL not supported on this transport`),
|
|
938
|
+
]);
|
|
939
|
+
expect(state.saslMech).toBeUndefined();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it('does not count the transport-level 904 toward the failure lockout', () => {
|
|
943
|
+
// A transport-capability rejection is not a credential attempt; a
|
|
944
|
+
// client probing EXTERNAL must not burn the SASL failure budget.
|
|
945
|
+
const state = saslReadyState();
|
|
946
|
+
const ctx = unsupportedCtx(state);
|
|
947
|
+
|
|
948
|
+
for (let i = 0; i < 3; i++) {
|
|
949
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
950
|
+
expect(firstLineText(out.effects[0])).toContain(
|
|
951
|
+
' 904 alice :SASL EXTERNAL not supported on this transport',
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
expect(state.saslFailures).toBeUndefined();
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it('lists PLAIN only in 908 when the transport override is set', () => {
|
|
958
|
+
const state = saslReadyState();
|
|
959
|
+
const ctx = unsupportedCtx(state);
|
|
960
|
+
const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
|
|
961
|
+
|
|
962
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
963
|
+
L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
|
|
964
|
+
]);
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
625
968
|
// ============================================================================
|
|
626
969
|
// AUTHENTICATE <unknown mechanism>
|
|
627
970
|
// ============================================================================
|
|
@@ -674,8 +1017,8 @@ describe('authenticateReducer — abort / already-authenticated', () => {
|
|
|
674
1017
|
|
|
675
1018
|
it('emits 907 when sent after a successful SASL authentication', () => {
|
|
676
1019
|
const state = saslReadyState();
|
|
677
|
-
const
|
|
678
|
-
const ctx = makeCtx(state,
|
|
1020
|
+
const services = registeredServices('alice', 'secret');
|
|
1021
|
+
const ctx = makeCtx(state, services);
|
|
679
1022
|
|
|
680
1023
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
681
1024
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -736,13 +1079,14 @@ describe('authenticateReducer — client abort', () => {
|
|
|
736
1079
|
describe('authenticateReducer — empty payload (`+`)', () => {
|
|
737
1080
|
it('treats `AUTHENTICATE +` as the final (empty) chunk and rejects malformed PLAIN', () => {
|
|
738
1081
|
const state = saslReadyState();
|
|
739
|
-
const
|
|
740
|
-
const
|
|
1082
|
+
const services = registeredServices('alice', 'secret');
|
|
1083
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
1084
|
+
const ctx = makeCtx(state, services);
|
|
741
1085
|
|
|
742
1086
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
743
1087
|
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
744
1088
|
|
|
745
|
-
expect(
|
|
1089
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
746
1090
|
expect(firstLineText(out.effects[0])).toContain(' 904 ');
|
|
747
1091
|
expect(state.saslMech).toBeUndefined();
|
|
748
1092
|
});
|
|
@@ -755,8 +1099,9 @@ describe('authenticateReducer — empty payload (`+`)', () => {
|
|
|
755
1099
|
describe('authenticateReducer — chunked payload', () => {
|
|
756
1100
|
it('buffers chunks of exactly 400 bytes and verifies on the short final chunk', () => {
|
|
757
1101
|
const state = saslReadyState();
|
|
758
|
-
const
|
|
759
|
-
const
|
|
1102
|
+
const services = registeredServices('alice', 'p'.repeat(320));
|
|
1103
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
1104
|
+
const ctx = makeCtx(state, services);
|
|
760
1105
|
|
|
761
1106
|
const longPassword = 'p'.repeat(320);
|
|
762
1107
|
const payload = plainPayload('alice', longPassword);
|
|
@@ -771,19 +1116,17 @@ describe('authenticateReducer — chunked payload', () => {
|
|
|
771
1116
|
const buffered = authenticateReducer(state, authenticate(first), ctx);
|
|
772
1117
|
expect(buffered.effects).toEqual([]);
|
|
773
1118
|
expect(state.saslMech).toBe('PLAIN');
|
|
774
|
-
expect(
|
|
1119
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
775
1120
|
|
|
776
1121
|
authenticateReducer(state, authenticate(rest), ctx);
|
|
777
|
-
expect(
|
|
778
|
-
{ mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: longPassword } },
|
|
779
|
-
]);
|
|
1122
|
+
expect(verifyNick).toHaveBeenCalledWith('alice', longPassword);
|
|
780
1123
|
expect(state.account).toBe('alice');
|
|
781
1124
|
});
|
|
782
1125
|
|
|
783
1126
|
it('rejects an oversized payload with 905 ERR_SASLTOOLONG', () => {
|
|
784
1127
|
const state = saslReadyState();
|
|
785
|
-
const
|
|
786
|
-
const ctx = makeCtx(state,
|
|
1128
|
+
const services = registeredServices('alice', 'secret');
|
|
1129
|
+
const ctx = makeCtx(state, services);
|
|
787
1130
|
|
|
788
1131
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
789
1132
|
const out = authenticateReducer(state, authenticate(`${'A'.repeat(8193)}`), ctx);
|
|
@@ -802,37 +1145,40 @@ describe('authenticateReducer — chunked payload', () => {
|
|
|
802
1145
|
describe('authenticateReducer — malformed PLAIN', () => {
|
|
803
1146
|
it('emits 904 when the base64 payload does not decode to three NUL-separated fields', () => {
|
|
804
1147
|
const state = saslReadyState();
|
|
805
|
-
const
|
|
806
|
-
const
|
|
1148
|
+
const services = registeredServices('alice', 'secret');
|
|
1149
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
1150
|
+
const ctx = makeCtx(state, services);
|
|
807
1151
|
|
|
808
1152
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
809
1153
|
const out = authenticateReducer(state, authenticate(encodeBase64('onlyonefield')), ctx);
|
|
810
1154
|
|
|
811
1155
|
expect(firstLineText(out.effects[0])).toContain(' 904 ');
|
|
812
|
-
expect(
|
|
1156
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
813
1157
|
});
|
|
814
1158
|
|
|
815
1159
|
it('emits 904 when the payload is not valid base64', () => {
|
|
816
1160
|
const state = saslReadyState();
|
|
817
|
-
const
|
|
818
|
-
const
|
|
1161
|
+
const services = registeredServices('alice', 'secret');
|
|
1162
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
1163
|
+
const ctx = makeCtx(state, services);
|
|
819
1164
|
|
|
820
1165
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
821
1166
|
const out = authenticateReducer(state, authenticate('!!!notbase64!!!'), ctx);
|
|
822
1167
|
|
|
823
1168
|
expect(firstLineText(out.effects[0])).toContain(' 904 ');
|
|
824
|
-
expect(
|
|
1169
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
825
1170
|
});
|
|
826
1171
|
|
|
827
1172
|
it('emits 908 when the first AUTHENTICATE frame is not a recognized mechanism', () => {
|
|
828
1173
|
const state = saslReadyState();
|
|
829
|
-
const
|
|
830
|
-
const
|
|
1174
|
+
const services = registeredServices('alice', 'secret');
|
|
1175
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
1176
|
+
const ctx = makeCtx(state, services);
|
|
831
1177
|
|
|
832
1178
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
833
1179
|
|
|
834
1180
|
expect(firstLineText(out.effects[0])).toContain(' 908 ');
|
|
835
|
-
expect(
|
|
1181
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
836
1182
|
});
|
|
837
1183
|
});
|
|
838
1184
|
|
|
@@ -879,8 +1225,8 @@ describe('authenticateReducer — CAP REQ sasl integration', () => {
|
|
|
879
1225
|
state.user = 'alice';
|
|
880
1226
|
state.host = 'example.com';
|
|
881
1227
|
state.registration = 'registering';
|
|
882
|
-
const
|
|
883
|
-
const ctx = makeCtx(state,
|
|
1228
|
+
const services = registeredServices('alice', 'secret');
|
|
1229
|
+
const ctx = makeCtx(state, services);
|
|
884
1230
|
|
|
885
1231
|
capReducer(state, { command: 'CAP', params: ['REQ', 'sasl'], tags: {} }, ctx);
|
|
886
1232
|
expect(state.caps.has('sasl')).toBe(true);
|
|
@@ -905,15 +1251,15 @@ describe('authenticateReducer — CAP REQ sasl integration', () => {
|
|
|
905
1251
|
});
|
|
906
1252
|
|
|
907
1253
|
// ============================================================================
|
|
908
|
-
// SASL PLAIN — NickServ-registered accounts (
|
|
1254
|
+
// SASL PLAIN — NickServ-registered accounts (single credential home)
|
|
909
1255
|
// ============================================================================
|
|
910
1256
|
|
|
911
1257
|
describe('authenticateReducer — NickServ-registered nick via ServicesStore', () => {
|
|
912
|
-
it('succeeds with credentials registered via NickServ
|
|
1258
|
+
it('succeeds with credentials registered via NickServ (no second registration)', () => {
|
|
913
1259
|
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
914
1260
|
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
915
1261
|
const state = saslReadyState();
|
|
916
|
-
const ctx = makeCtx(state,
|
|
1262
|
+
const ctx = makeCtx(state, services);
|
|
917
1263
|
|
|
918
1264
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
919
1265
|
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'hunter2')), ctx);
|
|
@@ -925,51 +1271,275 @@ describe('authenticateReducer — NickServ-registered nick via ServicesStore', (
|
|
|
925
1271
|
);
|
|
926
1272
|
});
|
|
927
1273
|
|
|
928
|
-
it('
|
|
1274
|
+
it('fails when neither the nick nor a matching credential verifies', () => {
|
|
929
1275
|
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
930
|
-
services.registerNick('
|
|
931
|
-
// Static AccountStore does not know about bob; the services store does.
|
|
932
|
-
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
1276
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
933
1277
|
const state = saslReadyState();
|
|
934
|
-
|
|
935
|
-
state.user = 'bob';
|
|
936
|
-
state.host = 'example.com';
|
|
937
|
-
const ctx = makeCtx(state, accounts, undefined, services);
|
|
1278
|
+
const ctx = makeCtx(state, services);
|
|
938
1279
|
|
|
939
1280
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
940
|
-
const out = authenticateReducer(state, authenticate(plainPayload('
|
|
1281
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
|
|
941
1282
|
|
|
942
|
-
expect(state.account).
|
|
943
|
-
expect(
|
|
944
|
-
`:${SV} 903 bob :SASL authentication successful`,
|
|
945
|
-
);
|
|
1283
|
+
expect(state.account).toBeUndefined();
|
|
1284
|
+
expect(firstLineText(out.effects[0])).toBe(`:${SV} 904 alice :SASL authentication failed`);
|
|
946
1285
|
});
|
|
1286
|
+
});
|
|
947
1287
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1288
|
+
// ============================================================================
|
|
1289
|
+
// Per-connection SASL failure lockout (brute-force hardening)
|
|
1290
|
+
// ============================================================================
|
|
1291
|
+
|
|
1292
|
+
/** Disconnect reason the reducer stamps once the failure lockout trips. */
|
|
1293
|
+
const SASL_LOCKOUT_REASON = 'too many failed SASL attempts';
|
|
1294
|
+
|
|
1295
|
+
describe('authenticateReducer — per-connection SASL failure lockout', () => {
|
|
1296
|
+
/**
|
|
1297
|
+
* Ctx builder with injectable clock + config so window/threshold tests can
|
|
1298
|
+
* advance time and override `sasl.maxFailures` / `sasl.failureWindowSeconds`.
|
|
1299
|
+
*/
|
|
1300
|
+
function lockoutCtx(
|
|
1301
|
+
state: ConnectionState,
|
|
1302
|
+
clock: FakeClock,
|
|
1303
|
+
config: ServerConfig = serverConfig,
|
|
1304
|
+
): Ctx {
|
|
1305
|
+
return buildCtx({
|
|
1306
|
+
serverConfig: config,
|
|
1307
|
+
clock,
|
|
1308
|
+
ids: new SequentialIdFactory(),
|
|
1309
|
+
motd: EmptyMotdProvider,
|
|
1310
|
+
connection: state,
|
|
1311
|
+
services: registeredServices('alice', 'secret'),
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Drives one full failed PLAIN exchange; returns the payload-frame effects. */
|
|
1316
|
+
function failedAttempt(state: ConnectionState, ctx: Ctx): EffectType[] {
|
|
1317
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
1318
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
|
|
1319
|
+
return out.effects;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
it('emits 904 without a disconnect for the first two failed attempts', () => {
|
|
1323
|
+
const state = saslReadyState();
|
|
1324
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1325
|
+
|
|
1326
|
+
const first = failedAttempt(state, ctx);
|
|
1327
|
+
expect(first).toEqual<EffectType[]>([
|
|
1328
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1329
|
+
]);
|
|
1330
|
+
|
|
1331
|
+
const second = failedAttempt(state, ctx);
|
|
1332
|
+
expect(second).toEqual<EffectType[]>([
|
|
1333
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1334
|
+
]);
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
it('records failed-attempt timestamps on the connection state', () => {
|
|
1338
|
+
const state = saslReadyState();
|
|
1339
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1340
|
+
|
|
1341
|
+
failedAttempt(state, ctx);
|
|
1342
|
+
failedAttempt(state, ctx);
|
|
1343
|
+
|
|
1344
|
+
expect(state.saslFailures).toEqual([1_000, 1_000]);
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
it('disconnects after the third failed attempt, 904 delivered first', () => {
|
|
1348
|
+
const state = saslReadyState();
|
|
1349
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1350
|
+
|
|
1351
|
+
failedAttempt(state, ctx);
|
|
1352
|
+
failedAttempt(state, ctx);
|
|
1353
|
+
const third = failedAttempt(state, ctx);
|
|
1354
|
+
|
|
1355
|
+
expect(third).toEqual<EffectType[]>([
|
|
1356
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1357
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1358
|
+
]);
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
it('honours a configured sasl.maxFailures override', () => {
|
|
1362
|
+
const config: ServerConfig = { ...serverConfig, sasl: { maxFailures: 2 } };
|
|
1363
|
+
const state = saslReadyState();
|
|
1364
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000), config);
|
|
1365
|
+
|
|
1366
|
+
failedAttempt(state, ctx);
|
|
1367
|
+
const second = failedAttempt(state, ctx);
|
|
1368
|
+
|
|
1369
|
+
expect(second).toEqual<EffectType[]>([
|
|
1370
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1371
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1372
|
+
]);
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
it('does not trip when prior failures fall outside the window (sliding window)', () => {
|
|
1376
|
+
const state = saslReadyState();
|
|
1377
|
+
const clock = new FakeClock(1_000);
|
|
1378
|
+
const ctx = lockoutCtx(state, clock);
|
|
1379
|
+
|
|
1380
|
+
failedAttempt(state, ctx); // t = 1_000
|
|
1381
|
+
clock.advance(30_000);
|
|
1382
|
+
failedAttempt(state, ctx); // t = 31_000
|
|
1383
|
+
clock.advance(31_000);
|
|
1384
|
+
const third = failedAttempt(state, ctx); // t = 62_000; t = 1_000 is 61s old → pruned
|
|
1385
|
+
|
|
1386
|
+
expect(third).toEqual<EffectType[]>([
|
|
1387
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1388
|
+
]);
|
|
1389
|
+
expect(state.saslFailures).toEqual([31_000, 62_000]);
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
it('trips once three failures land inside the window again after pruning', () => {
|
|
1393
|
+
const state = saslReadyState();
|
|
1394
|
+
const clock = new FakeClock(1_000);
|
|
1395
|
+
const ctx = lockoutCtx(state, clock);
|
|
1396
|
+
|
|
1397
|
+
failedAttempt(state, ctx); // t = 1_000
|
|
1398
|
+
clock.advance(30_000);
|
|
1399
|
+
failedAttempt(state, ctx); // t = 31_000
|
|
1400
|
+
clock.advance(31_000);
|
|
1401
|
+
failedAttempt(state, ctx); // t = 62_000 (count: 2)
|
|
1402
|
+
clock.advance(1_000);
|
|
1403
|
+
const tripping = failedAttempt(state, ctx); // t = 63_000 (31_000/62_000/63_000 in window)
|
|
1404
|
+
|
|
1405
|
+
expect(tripping).toEqual<EffectType[]>([
|
|
1406
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1407
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1408
|
+
]);
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it('honours a configured sasl.failureWindowSeconds override', () => {
|
|
1412
|
+
const config: ServerConfig = { ...serverConfig, sasl: { failureWindowSeconds: 1 } };
|
|
952
1413
|
const state = saslReadyState();
|
|
953
|
-
const
|
|
1414
|
+
const clock = new FakeClock(1_000);
|
|
1415
|
+
const ctx = lockoutCtx(state, clock, config);
|
|
1416
|
+
|
|
1417
|
+
failedAttempt(state, ctx); // t = 1_000
|
|
1418
|
+
clock.advance(1_100);
|
|
1419
|
+
failedAttempt(state, ctx); // t = 2_100 (t = 1_000 pruned)
|
|
1420
|
+
clock.advance(1_100);
|
|
1421
|
+
const third = failedAttempt(state, ctx); // t = 3_200 (t = 2_100 pruned)
|
|
1422
|
+
|
|
1423
|
+
expect(third).toEqual<EffectType[]>([
|
|
1424
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1425
|
+
]);
|
|
1426
|
+
expect(state.saslFailures).toEqual([3_200]);
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
it('resets the failure counter after a successful authentication', () => {
|
|
1430
|
+
const state = saslReadyState();
|
|
1431
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1432
|
+
|
|
1433
|
+
failedAttempt(state, ctx);
|
|
1434
|
+
failedAttempt(state, ctx);
|
|
1435
|
+
expect(state.saslFailures).toEqual([1_000, 1_000]);
|
|
954
1436
|
|
|
955
1437
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
956
|
-
authenticateReducer(state, authenticate(plainPayload('alice', '
|
|
1438
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
957
1439
|
|
|
958
|
-
// The static store vouched for the account; it is used.
|
|
959
|
-
expect(accounts.calls).toHaveLength(1);
|
|
960
1440
|
expect(state.account).toBe('alice');
|
|
1441
|
+
expect(state.saslFailures).toBeUndefined();
|
|
961
1442
|
});
|
|
962
1443
|
|
|
963
|
-
it('
|
|
964
|
-
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
965
|
-
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1444
|
+
it('does not count AUTHENTICATE * (abort) as a failure or reset the counter', () => {
|
|
966
1445
|
const state = saslReadyState();
|
|
967
|
-
const ctx =
|
|
1446
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1447
|
+
|
|
1448
|
+
failedAttempt(state, ctx); // count: 1
|
|
968
1449
|
|
|
1450
|
+
// Abort mid-exchange: clears the exchange scratch state but must neither
|
|
1451
|
+
// count as a failure nor let the attacker launder the counter away.
|
|
969
1452
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
970
|
-
const
|
|
1453
|
+
const abortOut = authenticateReducer(state, authenticate('*'), ctx);
|
|
1454
|
+
expect(firstLineText(abortOut.effects[0])).toContain(' 906 ');
|
|
971
1455
|
|
|
972
|
-
|
|
973
|
-
|
|
1456
|
+
failedAttempt(state, ctx); // count: 2 — no disconnect yet
|
|
1457
|
+
const third = failedAttempt(state, ctx); // count: 3 → trips
|
|
1458
|
+
|
|
1459
|
+
expect(third).toEqual<EffectType[]>([
|
|
1460
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1461
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1462
|
+
]);
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
it('does not count 908 unknown-mechanism rejections toward the lockout', () => {
|
|
1466
|
+
const state = saslReadyState();
|
|
1467
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1468
|
+
|
|
1469
|
+
for (let i = 0; i < 3; i++) {
|
|
1470
|
+
authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const attempt = failedAttempt(state, ctx);
|
|
1474
|
+
expect(attempt).toEqual<EffectType[]>([
|
|
1475
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1476
|
+
]);
|
|
1477
|
+
expect(state.saslFailures).toEqual([1_000]);
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it('does not count 905 ERR_SASLTOOLONG rejections toward the lockout', () => {
|
|
1481
|
+
const state = saslReadyState();
|
|
1482
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1483
|
+
|
|
1484
|
+
for (let i = 0; i < 3; i++) {
|
|
1485
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
1486
|
+
const out = authenticateReducer(state, authenticate(`${'A'.repeat(8193)}`), ctx);
|
|
1487
|
+
expect(firstLineText(out.effects[0])).toContain(' 905 ');
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const attempt = failedAttempt(state, ctx);
|
|
1491
|
+
expect(attempt).toEqual<EffectType[]>([
|
|
1492
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1493
|
+
]);
|
|
1494
|
+
expect(state.saslFailures).toEqual([1_000]);
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
it('counts malformed-base64 904 failures toward the lockout', () => {
|
|
1498
|
+
const state = saslReadyState();
|
|
1499
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1500
|
+
|
|
1501
|
+
for (let i = 0; i < 2; i++) {
|
|
1502
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
1503
|
+
const out = authenticateReducer(state, authenticate('!!!notbase64!!!'), ctx);
|
|
1504
|
+
expect(firstLineText(out.effects[0])).toContain(' 904 ');
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const third = failedAttempt(state, ctx);
|
|
1508
|
+
expect(third).toEqual<EffectType[]>([
|
|
1509
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1510
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1511
|
+
]);
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it('counts EXTERNAL verification failures toward the lockout', () => {
|
|
1515
|
+
const state = saslReadyState();
|
|
1516
|
+
const services = registeredServices('alice', 'secret');
|
|
1517
|
+
const provider = new FakeMtlsIdentityProvider('c1', undefined);
|
|
1518
|
+
const clock = new FakeClock(1_000);
|
|
1519
|
+
const ctx = buildCtx({
|
|
1520
|
+
// EXTERNAL must be opted in for the exchange to reach the verify
|
|
1521
|
+
// step whose failure the lockout counts.
|
|
1522
|
+
serverConfig: externalServerConfig,
|
|
1523
|
+
clock,
|
|
1524
|
+
ids: new SequentialIdFactory(),
|
|
1525
|
+
motd: EmptyMotdProvider,
|
|
1526
|
+
connection: state,
|
|
1527
|
+
services,
|
|
1528
|
+
mtlsIdentity: provider,
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
for (let i = 0; i < 2; i++) {
|
|
1532
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
1533
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
1534
|
+
expect(firstLineText(out.effects[0])).toContain(' 904 ');
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
1538
|
+
const third = authenticateReducer(state, authenticate('+'), ctx);
|
|
1539
|
+
|
|
1540
|
+
expect(third.effects).toEqual<EffectType[]>([
|
|
1541
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1542
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1543
|
+
]);
|
|
974
1544
|
});
|
|
975
1545
|
});
|