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:
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user