S00-017: Validate SCRAM server-final before authenticated SASL completion

Changes: Add an isolated Stanza 12.22.1 SASL feature replacement using existing SCRAM crypto, exact dependency lock and MIT notices, 44 integrated fixture checks, proposed ADR and review handoff. Leave production adapters and the original library experiment unchanged.
Validation: Fresh-cache npm ci and npm test passed on Node 24.21.0/npm 11.19.0; 44 tests cover RFC SHA-1/SHA-256 proofs and verified finals, stock defect reproduction, invalid/premature finals and async cleanup. make check passed 72 tooling tests. Pins/licenses/notices, unchanged vendor source and whitespace verified. Browser/WSS/server interoperability and constant-time assurance remain unverified; independent security review is pending.

Task: S00-017
Agent: auto-protocol-599343cd9e3f
Role: protocol
Action: claim
Status: review

Task title: Establish fail-closed SCRAM server-final validation
This commit is contained in:
wt committed 2026-10-15 02:17:46 +07:00
1 parent c73d9cef45
commit 493fbaebe7
18 files changed
+1043 -8

No files matched your search

@@ -0,0 +1,3 @@
engine-strict=true
strict-ssl=true
registry=https://registry.npmjs.org/
@@ -0,0 +1 @@
24.21.0
@@ -0,0 +1,14 @@
# Locked license inventory
Exact installed graph for the isolated Node experiment; no release/browser clearance.
| Package | Version | License |
| --- | --- | --- |
| @types/node | 22.20.5 | MIT |
| @types/punycode | 2.1.4 | MIT |
| @types/ws | 8.18.2 | MIT |
| punycode | 2.3.1 | MIT |
| sdp | 3.2.2 | MIT |
| stanza | 12.22.1 | MIT |
| undici-types | 6.21.0 | MIT |
| ws | 8.22.0 | MIT |
@@ -0,0 +1,76 @@
# SCRAM server-final validation experiment
S00-017 fixture-only remediation of S00-003. No network connection or production
adapter. Public RFC example credentials are used only in memory. Requires the
accepted Node 24.21.0 / npm 11.19.0 runtime; installs enforce exact engines and TLS.
From the project root, use the isolated runtime already verified by S00-002:
```sh
export PATH=/private/tmp/luma-stack-runtime/node-v24.21.0-darwin-arm64/bin:$PATH
node --version
npm --version
npm ci --prefix experiments/0002-xmpp-library/sasl-validation --ignore-scripts --no-audit --no-fund --cache /private/tmp/luma-sasl-review-cache --fetch-retries=0 --fetch-timeout=15000
npm test --prefix experiments/0002-xmpp-library/sasl-validation
```
On another host, obtain the official exact Node runtime with verified HTTPS and
checksum; do not change system configuration or disable certificates. Use a fresh
cache directory for an independent clean-install check. The original parent
experiment manifest remains incomplete and is not used by these commands.
The 44 tests use Stanza's actual Client, XML parse/registry, named SASL feature
handler, feature negotiation dispatcher and SCRAM implementation. Outgoing sends,
restart and disconnect are captured fixture effects, not server evidence. Tests
first reproduce stock 12.22.1 accepting missing/forged premature success, then
verify rejection with the replacement. RFC 5802 §5 / RFC 7677 §3 client-first,
client-proof and verified server-final are checked byte-for-byte for SHA-1/256.
No real credentials or key material are printed. A passing stock reproduction
means the defect was observed, not safe authentication.
`validated-sasl.mjs` installs through the public plugin and feature APIs:
```js
// Illustrative integration for a fresh Client, BEFORE any connect/features.
client.use(validatedSasl);
```
The plugin calls `registerFeature('sasl', 100, handler)`. Stanza's feature registry
uses one handler per name, so this replaces the stock callback. Registration also
adds another order entry; the real dispatcher exits on restart/disconnect, and
our integrated tests verify only one negotiation/completion, including after
reset. Do not install after authentication has started or alongside another SASL
replacement. The stock handler must never have an active event listener.
State is bounded to initial credentials/send, server-first, proof send,
server-final and closed. Success is allowed only after completed proof send,
strict final syntax, `processSuccess` and `finalize` reporting both authenticated
and mutuallyAuthenticated. Invalid input disconnects once, removes listeners
and never requests authenticated restart. Reset closes the old attempt before
awaited work resumes. Crypto remains entirely in the installed library, backed
by Node crypto in this run; no hash, HMAC, KDF or proof implementation is authored.
No credential cache/update is persisted by the replacement.
This is a deliberately limited non-PLUS SHA-256/SHA-1 experiment. It rejects
other mechanisms, supplied TLS binding bytes, extensions, extra challenges and
server-final delivered separately as a challenge. It accepts only server-final
additional-data on success. It checks nonce extension and bounds decoded data
at 4096 bytes and iterations at 100000; these are experiment limits, not shared
contracts/product policy. Racing success before a send promise resolves is
rejected conservatively. A final timeout/lifecycle policy belongs to the original
integration; this experiment does not claim one.
Exact dependency graph and preserved licenses: LICENSE_INVENTORY.md and
THIRD_PARTY_NOTICES.md. All eight locked packages are MIT; lock entries retain
HTTPS registry URLs and SHA512 integrity. Node/npm notices stay with their runtime.
Security review must inspect the state transitions and actual library calls,
repeat clean install/tests, check the diff, licenses and executor evidence, then
approve/reject through the task workflow. No browser build/execution, live WSS,
independent server, parser resource-limit certification, TLS channel binding,
constant-time verification audit, advisory/provenance audit, production SASL
policy or release readiness is claimed. Stanza's verifier uses ordinary string
equality; these tests prove rejection correctness, not a timing guarantee.
Browser Stanza uses its bundled synchronous hash/HMAC code and browser RNG/WS;
that backend is not exercised here. S00-003 retains its library selection,
browser/WSS/parser/extension experiment and stronger-mechanism policy work.
@@ -0,0 +1,208 @@
# Third-party notices
Installed package license texts, preserved for this experiment. Node/npm distribution notices remain with the isolated runtime.
## @types/node 22.20.5
```text
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
```
## @types/punycode 2.1.4
```text
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
```
## @types/ws 8.18.2
```text
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
```
## punycode 2.3.1
```text
Copyright Mathias Bynens <https://mathiasbynens.be/>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
## sdp 3.2.2
```text
Copyright (c) 2017 Philipp Hancke
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## stanza 12.22.1
```text
The MIT License (MIT)
Copyright © 2013-2019 Lance Stout
Copyright © 2013-2019 &yet <https://andyet.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
## undici-types 6.21.0
```text
MIT License
Copyright (c) Matteo Collina and Undici contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## ws 8.22.0
```text
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
Copyright (c) 2013 Arnout Kazemier and contributors
Copyright (c) 2016 Luigi Pinca and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
@@ -0,0 +1,99 @@
{
"name": "luma-scram-server-final-experiment",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "luma-scram-server-final-experiment",
"version": "0.0.0",
"dependencies": {
"stanza": "12.22.1"
},
"engines": {
"node": "24.21.0",
"npm": "11.19.0"
}
},
"node_modules/@types/node": {
"version": "22.20.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
"integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/punycode": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/punycode/-/punycode-2.1.4.tgz",
"integrity": "sha512-trzh6NzBnq8yw5e35f8xe8VTYjqM3NE7bohBtvDVf/dtUer3zYTLK1Ka3DG3p7bdtoaOHZucma6FfVKlQ134pQ==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/sdp": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
"license": "MIT"
},
"node_modules/stanza": {
"version": "12.22.1",
"resolved": "https://registry.npmjs.org/stanza/-/stanza-12.22.1.tgz",
"integrity": "sha512-WwoSl3u5cCEAbed5ao+yt8ByH6AsBJcPDY0gax3JGQkBX/7wEZ2oxu9nMJ7O1tm8slDyH4FBYIpeluHrT+g/LA==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.0.0",
"@types/punycode": "^2.1.0",
"@types/ws": "^8.18.1",
"punycode": "^2.1.1",
"sdp": "^3.0.2",
"ws": "^8.18.2"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
@@ -0,0 +1,10 @@
{
"name": "luma-scram-server-final-experiment",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {"node": "24.21.0", "npm": "11.19.0"},
"packageManager": "npm@11.19.0",
"scripts": {"test": "node --test test/*.test.mjs"},
"dependencies": {"stanza": "12.22.1"}
}
@@ -0,0 +1,19 @@
// Public RFC examples only. Not accounts, secrets, or network endpoints.
export const vectors = [
{
source: 'https://www.rfc-editor.org/rfc/rfc5802.html#section-5',
mechanism: 'SCRAM-SHA-1', username: 'user', password: 'pencil',
nonce: 'fyko+d2lbbFgONRv9qkxdawL',
first: 'r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096',
proof: 'c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=',
final: 'v=rmF9pqV8S7suAoZWja4dJRkFsKQ='
},
{
source: 'https://www.rfc-editor.org/rfc/rfc7677.html#section-3',
mechanism: 'SCRAM-SHA-256', username: 'user', password: 'pencil',
nonce: 'rOprNGfwEbeRWgbNEkqO',
first: 'r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096',
proof: 'c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=',
final: 'v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4='
}
];
@@ -0,0 +1,277 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {createRequire} from 'node:module';
import validatedSasl from '../validated-sasl.mjs';
import {vectors} from './fixtures.mjs';
const require = createRequire(import.meta.url);
const Client = require('stanza/Client').default;
const {parse} = require('stanza/jxt');
const tick = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => {
let resolve;
const promise = new Promise(r => {resolve = r;});
return {promise, resolve};
};
function harness(vector = vectors[1], fixed = true, mechanisms = [vector.mechanism]) {
const client = new Client({jid: 'user@example.invalid', autoReconnect: false,
credentials: {username: vector.username, password: vector.password, clientNonce: vector.nonce}});
const result = {sent: [], success: 0, failed: 0, restart: 0, disconnect: 0, verification: []};
// Capture fixture effects; use the real Client, XML registry, feature dispatcher,
// mechanism factory and SCRAM crypto. Never connect to a real provider.
client.send = async (kind, stanza) => {result.sent.push({kind, stanza});};
client.transport = {authenticated: false, restart: () => {result.restart++;}};
client.disconnect = async () => {result.disconnect++;};
client.on('auth:success', () => {result.success++;});
client.on('auth:failed', () => {result.failed++;});
if (fixed) {
client.use(validatedSasl);
const original = client.sasl.createMechanism.bind(client.sasl);
client.sasl.createMechanism = names => {
const mech = original(names);
if (mech) for (const method of ['processSuccess', 'finalize']) {
const fn = mech[method].bind(mech);
mech[method] = (...args) => {result.verification.push(method); return fn(...args);};
}
return mech;
};
}
const receive = async (type, value) => {
const body = value === undefined ? '' : Buffer.from(value).toString('base64');
const xml = parse(`<${type} xmlns="urn:ietf:params:xml:ns:xmpp-sasl">${body}</${type}>`);
const stanza = client.stanzas.import(xml);
assert.equal(stanza.type, type);
client.emit('sasl', stanza);
await tick();
};
const start = async () => {
client.emit('features', {sasl: {mechanisms}});
await tick();
};
return {client, result, receive, start};
}
function rejected(h) {
assert.equal(h.result.success, 0);
assert.equal(h.result.restart, 0);
assert.equal(h.result.failed, 1);
assert.equal(h.result.disconnect, 1);
assert.equal(h.client.features.negotiated.sasl, false);
assert.equal(h.client.transport.authenticated, false);
assert.equal(h.client.listenerCount('sasl'), 0);
}
for (const vector of vectors) {
test(`${vector.mechanism}: RFC proof and verified final through feature dispatcher`, async () => {
const h = harness(vector);
await h.start();
assert.equal(h.result.sent[0].stanza.value.toString(), `n,,n=user,r=${vector.nonce}`);
await h.receive('challenge', vector.first);
assert.equal(h.result.sent[1].stanza.value.toString(), vector.proof);
assert.equal(h.result.success, 0);
assert.equal(h.result.restart, 0);
assert.equal(h.client.transport.authenticated, false);
await h.receive('success', vector.final);
assert.deepEqual(h.result.verification, ['processSuccess', 'finalize']);
assert.equal(h.result.success, 1);
assert.equal(h.result.restart, 1);
assert.equal(h.result.disconnect, 0);
assert.equal(h.client.features.negotiated.sasl, true);
assert.equal(h.client.transport.authenticated, true);
await h.receive('success', vector.final);
assert.equal(h.result.success, 1);
assert.equal(h.result.restart, 1);
});
for (const [label, final] of [
['missing', undefined],
['forged', vector.final.replace(/v=./, 'v=A')],
['error', 'e=invalid-proof'],
['conflicting error', `${vector.final},e=invalid-proof`],
['duplicate verifier', `${vector.final},${vector.final}`],
['bad base64', 'v=***'],
['oversized', 'v=' + 'A'.repeat(4096)]
]) {
test(`${vector.mechanism}: reject ${label} server-final after proof`, async () => {
const h = harness(vector);
await h.start();
await h.receive('challenge', vector.first);
await h.receive('success', final);
rejected(h);
if (label === 'forged') assert.deepEqual(h.result.verification, ['processSuccess', 'finalize']);
await h.receive('success', vector.final);
rejected(h);
});
}
test(`${vector.mechanism}: reject valid-looking final before proof`, async () => {
const h = harness(vector);
await h.start();
await h.receive('success', vector.final);
rejected(h);
assert.deepEqual(h.result.verification, []);
});
}
for (const [label, final] of [['missing', undefined], ['forged', 'v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']]) {
test(`stock 12.22.1 reproduces ${label} premature acceptance`, async () => {
const h = harness(vectors[1], false);
await h.start();
await h.receive('success', final);
assert.equal(h.result.success, 1);
assert.equal(h.result.restart, 1);
assert.equal(h.client.transport.authenticated, true);
});
test(`replacement rejects original ${label} premature defect`, async () => {
const h = harness();
await h.start();
await h.receive('success', final);
rejected(h);
});
}
for (const [label, first] of [
['wrong nonce', vectors[1].first.replace('r=rOpr', 'r=wrong')],
['unextended nonce', `r=${vectors[1].nonce},s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096`],
['iteration overflow', vectors[1].first.replace('i=4096', 'i=100001')],
['invalid iterations', vectors[1].first.replace('i=4096', 'i=NaN')],
['duplicate attribute', vectors[1].first + ',i=4096'],
['mandatory extension', 'm=required,' + vectors[1].first]
]) {
test(`reject ${label} before library proof work`, async () => {
const h = harness();
await h.start();
await h.receive('challenge', first);
rejected(h);
assert.equal(h.result.sent.length, 1);
});
}
test('reject success while proof send is pending; no late revival', async () => {
const h = harness();
await h.start();
const pending = deferred();
h.client.send = () => pending.promise;
await h.receive('challenge', vectors[1].first);
await h.receive('success', vectors[1].final);
rejected(h);
pending.resolve();
await tick();
await h.receive('success', vectors[1].final);
rejected(h);
});
test('duplicate challenge while sending proof fails closed', async () => {
const h = harness();
await h.start();
const pending = deferred();
h.client.send = () => pending.promise;
await h.receive('challenge', vectors[1].first);
await h.receive('challenge', vectors[1].first);
rejected(h);
pending.resolve();
await tick();
rejected(h);
});
test('reset while awaiting credentials prevents late auth and listener leak', async () => {
const h = harness();
const pending = deferred();
h.client.getCredentials = () => pending.promise;
await h.start();
h.client.emit('--reset-stream-features');
pending.resolve({username: 'user', password: 'pencil', clientNonce: vectors[1].nonce});
await tick();
await h.receive('success', vectors[1].final);
assert.equal(h.result.sent.length, 0);
assert.equal(h.result.success, 0);
assert.equal(h.result.restart, 0);
assert.equal(h.client.listenerCount('sasl'), 0);
});
test('reset after proof, new negotiation succeeds without old handlers', async () => {
const h = harness();
await h.start();
await h.receive('challenge', vectors[1].first);
h.client.emit('--reset-stream-features');
await h.receive('success', vectors[1].final);
assert.equal(h.result.success, 0);
assert.equal(h.client.listenerCount('sasl'), 0);
await h.start();
assert.equal(h.client.listenerCount('sasl'), 1);
await h.receive('challenge', vectors[1].first);
await h.receive('success', vectors[1].final);
assert.equal(h.result.success, 1);
assert.equal(h.result.restart, 1);
});
test('fixture policy filters PLUS and plaintext; strongest available non-PLUS selected', async () => {
const h = harness(vectors[1], true, ['PLAIN', 'SCRAM-SHA-1', 'SCRAM-SHA-256-PLUS', 'SCRAM-SHA-256']);
await h.start();
assert.equal(h.result.sent[0].stanza.mechanism, 'SCRAM-SHA-256');
});
for (const names of [['PLAIN'], ['SCRAM-SHA-256-PLUS'], []]) {
test(`no allowed mechanism (${names.join(',')}) disconnects`, async () => {
const h = harness(vectors[1], true, names);
await h.start();
rejected(h);
assert.equal(h.result.sent.length, 0);
});
}
for (const type of ['failure', 'abort']) {
test(`${type} is terminal; valid final cannot revive it`, async () => {
const h = harness();
await h.start();
await h.receive(type);
await h.receive('success', vectors[1].final);
rejected(h);
});
}
test('success during pending initial credentials fails closed', async () => {
const h = harness();
const pending = deferred();
h.client.getCredentials = () => pending.promise;
await h.start();
await h.receive('success', vectors[1].final);
rejected(h);
pending.resolve({username: 'user', password: 'pencil', clientNonce: vectors[1].nonce});
await tick();
assert.equal(h.result.sent.length, 0);
rejected(h);
});
test('reset during proof send cancels continuation', async () => {
const h = harness();
await h.start();
const pending = deferred();
h.client.send = () => pending.promise;
await h.receive('challenge', vectors[1].first);
h.client.emit('--reset-stream-features');
pending.resolve();
await tick();
await h.receive('success', vectors[1].final);
assert.equal(h.result.success, 0);
assert.equal(h.result.restart, 0);
assert.equal(h.client.listenerCount('sasl'), 0);
});
for (const stage of ['credentials', 'initial send', 'proof send']) {
test(`${stage} rejection disconnects without success`, async () => {
const h = harness();
const reject = async () => {throw new Error('synthetic fixture failure');};
if (stage === 'credentials') h.client.getCredentials = reject;
if (stage === 'initial send') h.client.send = reject;
await h.start();
if (stage === 'proof send') {
h.client.send = reject;
await h.receive('challenge', vectors[1].first);
}
rejected(h);
});
}
test('repeat challenge after completed proof is rejected', async () => {
const h = harness();
await h.start();
await h.receive('challenge', vectors[1].first);
await h.receive('challenge', vectors[1].first);
rejected(h);
});
@@ -0,0 +1,108 @@
// Fixture-bound feature replacement. Cryptographic operations stay in Stanza SCRAM.
// No production transport, credential persistence, or channel binding is provided.
const SUPPORTED = new Set(['SCRAM-SHA-256', 'SCRAM-SHA-1']);
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
function text(value) {
// SASL's parsed additional-data is a Buffer, not raw XML/base64 text.
if (!value || typeof value.toString !== 'function' || value.length > 4096) {
throw new Error('Invalid SASL data');
}
const result = value.toString('utf8');
if (!/^[\x21-\x7e]*$/.test(result)) throw new Error('Invalid SASL data');
return result;
}
function firstMessage(value, nonce) {
// Deliberately narrow, no extensions. Bound work before calling library crypto.
const match = /^r=([\x21-\x2b\x2d-\x7e]+),s=([A-Za-z0-9+/=]+),i=([1-9][0-9]*)$/.exec(text(value));
if (!match || !BASE64.test(match[2]) || !match[1].startsWith(nonce) ||
match[1].length <= nonce.length || Number(match[3]) > 100000) {
throw new Error('Invalid SCRAM server-first');
}
}
function finalMessage(value, name) {
const final = text(value);
const length = name === 'SCRAM-SHA-256' ? 44 : 28;
if (!final.startsWith('v=') || final.length !== length + 2 || !BASE64.test(final.slice(2))) {
throw new Error('Invalid SCRAM server-final');
}
}
export default function validatedSasl(client) {
let active;
// Public plugin + named feature registration replaces the stock handler before use.
client.registerFeature('sasl', 100, async (features, done) => {
if (active) active();
const names = features.sasl.mechanisms.filter(name => SUPPORTED.has(name));
const mech = client.sasl.createMechanism(names);
let phase = 'starting';
let credentials;
let nonce;
const cleanup = () => {
client.off('sasl', handler);
client.off('--reset-stream-features', reset);
if (active === reset) active = undefined;
};
const reset = () => {
phase = 'closed';
cleanup();
client.features.negotiated.sasl = false;
};
const fail = () => {
if (phase === 'closed') return;
phase = 'closed';
cleanup();
client.features.negotiated.sasl = false;
if (client.transport) client.transport.authenticated = false;
client.emit('auth:failed');
done('disconnect', 'SCRAM validation failed'); // Never log peer data/credentials.
};
const handler = async sasl => {
if (phase === 'closed') return;
try {
if (sasl.type === 'challenge' && phase === 'server-first') {
phase = 'sending-proof'; // Reject duplicate challenges and racing success.
firstMessage(sasl.value, nonce);
mech.processChallenge(sasl.value);
const response = mech.createResponse(credentials);
await client.send('sasl', {type: 'response', value: response});
if (phase !== 'closed') phase = 'server-final';
return;
}
if (sasl.type !== 'success' || phase !== 'server-final') return fail();
finalMessage(sasl.value, mech.name);
mech.processSuccess(sasl.value);
const result = mech.finalize();
if (!result.authenticated || !result.mutuallyAuthenticated) return fail();
phase = 'closed';
cleanup();
client.features.negotiated.sasl = true;
if (client.transport) client.transport.authenticated = true;
client.emit('auth:success', client.config.credentials);
done('restart');
} catch {
fail();
}
};
active = reset;
client.features.negotiated.sasl = false;
client.on('sasl', handler);
client.once('--reset-stream-features', reset);
try {
if (!mech || !SUPPORTED.has(mech.name)) return fail();
credentials = await client.getCredentials();
if (phase === 'closed') return;
// Restrict this experiment to non-PLUS; never invent TLS binding bytes.
if (credentials.tlsUnique) return fail();
const response = mech.createResponse(credentials);
nonce = /,r=([^,]+)$/.exec(response.toString('utf8'))?.[1];
if (!nonce) return fail();
await client.send('sasl', {type: 'auth', mechanism: mech.name, value: response});
if (phase !== 'closed') phase = 'server-first';
} catch {
fail();
}
});
}