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

+112
View File
@@ -0,0 +1,112 @@
# ADR 0002-SASL: Fail-closed SCRAM server-final experiment
- Status: proposed; requires independent security review of S00-017.
- Task: S00-017, remediation for S00-003.
- Date: 2026-10-15.
- Owner: auto-protocol-599343cd9e3f (protocol).
- Reviewer: assigned security role, decision pending.
- Requirements: ACC-004, NET-003, NET-004; accepted ADR 0000/0001 preserved.
## Question and bounded result
Can the pinned StanzaJS candidate validate SCRAM server authentication before
emitting success/restarting without modifying vendor source or implementing
cryptographic primitives? Output is one isolated, runnable feature experiment,
not selection of Stanza as the production library. S01 shared contracts remain
unaccepted; no application adapter or interface is created here.
The original blocker is reproducible in 12.22.1: its SASL success branch marks
negotiation/transport authenticated, emits auth:success and requests restart
without processing additional-data or calling the SCRAM verifier. Empty and
forged success before challenge/proof are accepted. The isolated tests retain
this baseline reproduction and exercise the replacement through the real XML
registry and feature dispatcher.
## Options and proposed decision
1. **Replace the named SASL feature through public APIs.** Chosen for this bounded
experiment. `Client.use` and `registerFeature` are exposed in installed package
declarations; the registry assigns handlers by name. SCRAM already exposes
`processSuccess` and `finalize`. Keep all crypto in that existing implementation
and add fail-closed ordering, strict fixture-profile input and cleanup.
2. **Patch ignored vendor code or intercept success after emission.** Rejected:
ignored patches are not reproducible deliverables, and a post-success listener
cannot prevent earlier authenticated effects. No upstream patch is required.
3. **Replace the entire client/mechanism stack.** Deferred to S00-003. The original
evidence's xmpp.js source comparison also lacked server-final processing;
no installed/tested alternative or maintenance/security superiority is claimed.
Install the proposed plugin on a fresh Client before connect/feature events;
never permit the stock SASL handler to start first. The named registration
replaces its callback without editing registry internals. The API appends an
order entry; the actual dispatcher exits on restart/disconnect, with tests
confirming one completion and successful later negotiation after reset.
Only after proof send completes does success data reach library `processSuccess`
and `finalize`; both authenticated and mutuallyAuthenticated must be true before
any authenticated flag, event or restart. Missing, forged, erroneous, duplicated,
malformed or premature final is terminal failure. Reset removes attempt listeners
and cancels asynchronous continuation. Proof/credential/send exceptions fail
without logging credentials, peer data or crypto state. No cache is persisted.
## Sources, pins and licenses
Primary sources inspected on 2026-10-15:
- [RFC 5802 §5](https://www.rfc-editor.org/rfc/rfc5802.html#section-5): mutual
authentication and SHA-1 exchange; the public user/pencil fixture is copied as
test data, not used as an account. Invalid verifier must fail the exchange.
- [RFC 7677 §3](https://www.rfc-editor.org/rfc/rfc7677.html#section-3): SHA-256
exchange used as a second fixed primary-spec transcript.
- [Stanza 12.22.1 registry record](https://registry.npmjs.org/stanza/12.22.1):
exact package provenance location/license and locked tarball. Installed shipped
`Client.js/.d.ts`, `plugins/features.js/.d.ts`, `plugins/sasl.js` and
`lib/sasl/index.js/.d.ts` are the exact API/implementation evidence. Upstream
[Client source](https://github.com/legastero/stanza/blob/master/src/Client.ts)
corroborates plugin usage, but is not version-pinned acceptance evidence.
Dependency: stanza 12.22.1, MIT. Exact transitive pins: @types/node 22.20.5,
@types/punycode 2.1.4, @types/ws 8.18.2, punycode 2.3.1, sdp 3.2.2,
undici-types 6.21.0, ws 8.22.0; all MIT. Separate manifest/lock under
`experiments/0002-xmpp-library/sasl-validation` does not alter the parent's
unaccepted exploratory graph. Every locked package has HTTPS registry URL,
SHA512 integrity and inspected installed license text; inventory and notices
are preserved. Runtime stays Node 24.21.0/npm 11.19.0 from accepted ADR 0001.
No new crypto dependency, crypto primitive or vendor-source edit is introduced.
## Runnable evidence and constraints
See experiment README and `state/evidence/S00-017-auto-protocol-599343cd9e3f.md`.
Clean npm ci with an empty cache and npm test passed: 44 tests, including two
RFC client proofs/verified finals, original insecure baseline, missing/forged/
premature rejection, duplicate/out-of-order inputs, send/credential failure,
asynchronous reset and replay rejection. Tests observe real feature-dispatcher
restart/disconnect calls and transport/negotiation flags; they are local parsed
XML integration tests with captured outbound effects, not WSS/server tests.
The experiment accepts only non-PLUS SHA-256/SHA-1, selecting the strongest of
those offered and failing if neither is available. No plaintext fallback or
invented browser TLS binding. SHA-512, complete mechanism policy and compatibility
remain S00-003. Server-final is accepted only as success additional-data; separate
final challenge, SCRAM extensions and extra challenge round trips are rejected.
Decoded-data and iteration bounds (4096 bytes / 100000) are local experiment
limits. A success racing a pending send is conservatively rejected. These
restrictions can reduce availability and need explicit future integration review.
Node exercises Stanza's SCRAM composition and Node crypto primitives. Browser
Stanza uses bundled synchronous hashing/HMAC, global crypto RNG, WebSocket and
fetch; its backend/build is not tested in this task. No browser support matrix,
constant-time comparison guarantee (Stanza compares verifier strings), advisory/
provenance clearance, WSS/TLS/channel-binding test, independent-client/server
interoperability or production readiness is implied. No live credentials,
network authentication, certificate/CORS bypass or persisted secrets are used.
## Handoff and consequences
Independent security reviewer must reproduce the integrated positive/negative
cases and assess extension API, cleanup, license/pin evidence and the stated
restrictions. This ADR stays proposed until review. Reviewed acceptance permits
a fresh S00-003 executor to evaluate this approach in its own experiment; it does
not implement that original task or change the accepted stack/shared contracts.
Any required broader policy/interface or vendor replacement is a separately
scoped plan decision. Coordinator owns transition commits and fresh processes.
@@ -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();
}
});
}
+2 -2
View File
@@ -1,6 +1,6 @@
# Матрица задач
Generated; state revision **38**. Canonical status: state/tasks.json.
Generated; state revision **40**. Canonical status: state/tasks.json.
| ID | Этап | Оператор | Reviewer | Статус | Owner | Зависимости | Задача |
| --- | --- | --- | --- | --- | --- | --- | --- |
@@ -19,7 +19,7 @@ Generated; state revision **38**. Canonical status: state/tasks.json.
| [S00-014](tasks/S00-014.md) | S00 | ci-cd | qa | done | auto-ci-cd-14343249da62 | S00-001, S00-011, S00-013 | Fix repeated runner cleanup after a submitted parent is reaped |
| [S00-015](tasks/S00-015.md) | S00 | ci-cd | qa | done | auto-ci-cd-84e209103f24 | S00-001, S00-011, S00-013, S00-014, S00-016 | Устранить прерывание: Fix runner prompt delivery after partial stdin timeout |
| [S00-016](tasks/S00-016.md) | S00 | ci-cd | qa | done | auto-ci-cd-351a0cbbabef | S00-001, S00-011, S00-013, S00-014 | Restore reviewer model availability after provider capacity failure |
| [S00-017](tasks/S00-017.md) | S00 | protocol | security | todo | | S00-001, S00-002 | Establish fail-closed SCRAM server-final validation |
| [S00-017](tasks/S00-017.md) | S00 | protocol | security | review | auto-protocol-599343cd9e3f | S00-001, S00-002 | Establish fail-closed SCRAM server-final validation |
| [S00-010](tasks/S00-010.md) | S00 | qa | orchestrator | todo | | S00-001, S00-002, S00-003, S00-004, S00-005, S00-006, S00-007, S00-008, S00-009 | Gate: Профиль и проверка стека |
| [S01-001](tasks/S01-001.md) | S01 | orchestrator | security | todo | | S00-010 | Описать identity/model contracts |
| [S01-002](tasks/S01-002.md) | S01 | orchestrator | security | todo | | S00-010, S01-001 | Описать async account context |
+1 -1
View File
@@ -21,7 +21,7 @@
- [S00-014](../tasks/S00-014.md): Fix repeated runner cleanup after a submitted parent is reaped — ci-cd; done.
- [S00-015](../tasks/S00-015.md): Устранить прерывание: Fix runner prompt delivery after partial stdin timeout — ci-cd; done.
- [S00-016](../tasks/S00-016.md): Restore reviewer model availability after provider capacity failure — ci-cd; done.
- [S00-017](../tasks/S00-017.md): Establish fail-closed SCRAM server-final validation — protocol; todo.
- [S00-017](../tasks/S00-017.md): Establish fail-closed SCRAM server-final validation — protocol; review.
- [S00-010](../tasks/S00-010.md): Gate: Профиль и проверка стека — qa; todo.
## Граница этапа
+1 -1
View File
@@ -14,7 +14,7 @@ S00-013,S00,ci-cd,qa,done,auto-ci-cd-5f68f03dd33b,"S00-001, S00-011",Bound overs
S00-014,S00,ci-cd,qa,done,auto-ci-cd-14343249da62,"S00-001, S00-011, S00-013",Fix repeated runner cleanup after a submitted parent is reaped
S00-015,S00,ci-cd,qa,done,auto-ci-cd-84e209103f24,"S00-001, S00-011, S00-013, S00-014, S00-016",Устранить прерывание: Fix runner prompt delivery after partial stdin timeout
S00-016,S00,ci-cd,qa,done,auto-ci-cd-351a0cbbabef,"S00-001, S00-011, S00-013, S00-014",Restore reviewer model availability after provider capacity failure
S00-017,S00,protocol,security,todo,,"S00-001, S00-002",Establish fail-closed SCRAM server-final validation
S00-017,S00,protocol,security,review,auto-protocol-599343cd9e3f,"S00-001, S00-002",Establish fail-closed SCRAM server-final validation
S00-010,S00,qa,orchestrator,todo,,"S00-001, S00-002, S00-003, S00-004, S00-005, S00-006, S00-007, S00-008, S00-009",Gate: Профиль и проверка стека
S01-001,S01,orchestrator,security,todo,,S00-010,Описать identity/model contracts
S01-002,S01,orchestrator,security,todo,,"S00-010, S01-001",Описать async account context
1 id stage operator reviewer status owner depends_on title
14 S00-014 S00 ci-cd qa done auto-ci-cd-14343249da62 S00-001, S00-011, S00-013 Fix repeated runner cleanup after a submitted parent is reaped
15 S00-015 S00 ci-cd qa done auto-ci-cd-84e209103f24 S00-001, S00-011, S00-013, S00-014, S00-016 Устранить прерывание: Fix runner prompt delivery after partial stdin timeout
16 S00-016 S00 ci-cd qa done auto-ci-cd-351a0cbbabef S00-001, S00-011, S00-013, S00-014 Restore reviewer model availability after provider capacity failure
17 S00-017 S00 protocol security todo review auto-protocol-599343cd9e3f S00-001, S00-002 Establish fail-closed SCRAM server-final validation
18 S00-010 S00 qa orchestrator todo S00-001, S00-002, S00-003, S00-004, S00-005, S00-006, S00-007, S00-008, S00-009 Gate: Профиль и проверка стека
19 S01-001 S01 orchestrator security todo S00-010 Описать identity/model contracts
20 S01-002 S01 orchestrator security todo S00-010, S01-001 Описать async account context
@@ -0,0 +1,4 @@
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.
@@ -0,0 +1,46 @@
# S00-017 executor evidence
Agent: auto-protocol-599343cd9e3f / protocol. Date: 2026-10-15.
Result: isolated fail-closed SCRAM experiment and proposed ADR; independent security review pending.
## Input, output and scope
Input: assigned S00-017 card/context, S00-003 blocker/evidence/handoff, exact ACC-004/NET-003/NET-004, accepted scope/stack ADRs and direct dependency evidence/review/handoffs. Required TESTING, decisions/README and state/blockers/S00-017.md read. Read master/startup/protocol/common and architecture/contracts/workflow/automation/Git instructions. S01 contracts remain future work; no shared contract is needed/introduced here.
Output: experiments/0002-xmpp-library/sasl-validation plus docs/decisions/0002-xmpp-sasl-validation.md only, with fixture tests, separate exact manifest/lock, engine/TLS settings, license inventory/notices and reproduction instructions. Workflow evidence/handoff/commit message are the allowed metadata exception. Parent exploratory manifest/lock, specs, shared interfaces, application modules and planning definitions unchanged. No Git mutations or additional task/agent launch. No secrets or real account data: only public RFC example credentials, and invented example.invalid JID, in memory without network authentication.
Acceptance demonstrated locally: missing/forged/premature server-final cannot emit success or restart; valid RFC SCRAM must compute exact client proof and call the actual library verifier before setting authenticated flags. Completion of S00-017 still requires independent security review; this executor does not mark done or accept the ADR.
## Actual checks and exit codes
All npm commands used PATH=/private/tmp/luma-stack-runtime/node-v24.21.0-darwin-arm64/bin:$PATH (existing accepted isolated runtime), from project root. No system runtime install or certificate override.
- python3 scripts/tasks.py resume --agent auto-protocol-599343cd9e3f; show S00-017; packet S00-017 --agent auto-protocol-599343cd9e3f: exit 0 each; correct assignment/prerequisites and exact requirements, no second claim.
- npm install --ignore-scripts --no-audit --no-fund --cache /private/tmp/luma-sasl-cache --fetch-retries=0 --fetch-timeout=15000 --registry=https://registry.npmjs.org/ --strict-ssl=true --prefix experiments/0002-xmpp-library/sasl-validation: exit 0; eight installed packages and independent lock generated.
- npm test --prefix experiments/0002-xmpp-library/sasl-validation: exit 0 initially, 38 tests; includes actual stock defect reproduction and positive/negative replacement checks. Added further lifecycle/error coverage afterward.
- npm ci --ignore-scripts --no-audit --no-fund --cache /private/tmp/luma-sasl-clean-599343cd9e3f --fetch-retries=0 --fetch-timeout=15000 --prefix experiments/0002-xmpp-library/sasl-validation: exit 0; fresh cache, eight packages, lifecycle scripts disabled, strict TLS/engines retained.
- npm test --prefix experiments/0002-xmpp-library/sasl-validation after clean install: exit 0; 44 passed, zero failed/skipped/cancelled; duration 1039.675 ms. No source changes to plugin/tests afterward.
- node --version; npm --version with pinned PATH: exit 0 each; v24.21.0 / 11.19.0.
- Inline Python JSON/license checks: successful collector exit 0; checked eight exact installed versions/licenses against lock, all HTTPS registry URLs/SHA512 integrity, preserved installed license texts. All eight MIT. Separate post-ci assertions exit 0 verify preserved notices still match installed license texts, and Client/SASL/features/SCRAM vendor files byte-match parent installed original sources. No vendor source edit.
- curl --fail --silent --show-error https://www.rfc-editor.org/rfc/rfc7677.txt (displayed §3 through sed): exit 0; canonical SHA-256 fixture obtained. RFC 5802 §5 and RFC 7677 opened through web tool; installed package declarations/source provide exact extension API and crypto behavior.
- make check: exit 0; validate 282 tasks, 22 stages, 221 requirements, 211 allocated Web requirements, revision 39; 72 tooling tests passed in 17.977 s. Foundation integrity, not app readiness.
- git diff --check; git status --short --untracked-files=all: exit 0 each; only scoped source and coordinator's state claim before evidence. Separate inline Python whitespace check of authored files (including untracked files): exit 0.
Exploratory failures, not successful checks: cat of guessed 0001-web-stack.md and ls of guessed runtime returned exit 1 before locating actual 0001-stack.md and runtime via existing evidence; no required artifact missing. A guessed installed protocol/sasl.js read returned exit 1 (registry APIs instead inspected at actual jxt and feature paths). First license collector returned exit 1 because punycode uses LICENSE-MIT.txt; corrected filename discovery, then all eight notices verified. Some GitHub source-page requests failed in web tool; installed exact declarations/source supplied version-pinned facts, and upstream Client source was retrievable. No acceptance blocker remained; no failed command is described as passed.
## Integrated results and security boundaries
Tests run the actual Client, parse and import SASL XML through the actual registry, invoke feature negotiation by emitting features, and observe resulting transport restart/disconnect, negotiated/authenticated flags and auth events. Fixture capture replaces outgoing send/transport effects only; no mechanism verifier or proof result is stubbed. Method tracing wraps and invokes real processSuccess/finalize; both RFC proofs match exactly. Stock baseline tests return success/restart on missing and forged premature final, confirming the original defect. Replacement tests reject those same categories and a valid-looking final before proof.
Remaining cases: two SHA variants reject missing/forged/error/conflicting error/duplicate verifier/bad base64/oversized final after proof; wrong/unextended nonce and invalid/excessive iteration inputs rejected before proof work; duplicate challenge, pending-send premature success, failure/abort and replay are terminal; reset cancels pending credentials/proof-send continuation and allows fresh negotiation without old listeners. Credential/send rejection disconnects. PLUS/plaintext-only offers fail; strongest supported non-PLUS chosen. Success emits once only after signature verification; invalid attempts never request restart or set authenticated state.
Public Client.use + registerFeature replaces the named stock handler before negotiation. No late event interception or private vendor patch. Crypto stays in existing Stanza SCRAM/Node crypto. Bounds/strict syntax restrict the experiment and prevent Stanza's permissive directive parser from ignoring conflicting attributes. Library cache is not persisted/emitted by replacement. No peer/credential/crypto log output, plaintext fallback, encryption/CORS/certificate bypass or emulated TLS binding.
## Digests and limits
SHA256 package-lock.json: 6413668910ec921d7871225f22fe2753ceaf32c6c185cda6d0a5fd0e27643514.
SHA256 validated-sasl.mjs: 196b16baa16fea051dc6502bae11f80ebea02f87634d90ea72778ee14557a3b9.
Only success additional-data, non-PLUS SHA-256/SHA-1 and extension-free fixture profile are supported; separate final challenge and extra challenge roundtrips conservatively fail. Local bounds are 4096 decoded bytes and 100000 iterations; racing success while send is pending fails closed. No timeout policy is claimed. Stanza verifier uses string equality; no constant-time assurance. Node executes actual crypto; browser bundled hashing/RNG backend, browser build/runtime, WSS/TLS/server interoperability, full parser limits, SHA-512/complete mechanism policy, vulnerability/provenance audit and release readiness remain unverified and belong to S00-003 or later tasks. No browser availability claim is needed for this Node fixture task. Supported extension evidence is public shipped declarations and actual dispatcher behavior, not a promise of future library API stability.
Separate security reviewer should follow README, inspect the actual source and pinned graph/notices, repeat clean install/tests, assess limitations and record independent acceptance/rejection. S00-003 remains separate and blocked until reviewed done of this remediation; executor does not continue it.
+31
View File
@@ -0,0 +1,31 @@
# S00-017 handoff for independent security review
Executor: auto-protocol-599343cd9e3f (protocol), 2026-10-15.
Outcome: proposed docs/decisions/0002-xmpp-sasl-validation.md and isolated
experiments/0002-xmpp-library/sasl-validation. No blocker. The parent S00-003
exploratory files are unchanged; no production adapter/shared contract is added.
Actual result: clean npm ci with fresh cache and pinned Node 24.21.0/npm 11.19.0,
then npm test: 44 tests passed. Real Client/XML registry/feature dispatcher/SCRAM
crypto used. Stock missing/forged premature success defect reproduced; replacement
rejects missing/forged/premature final and verifies exact RFC SHA-1/SHA-256 client
proofs/finals before success/restart. Async reset, send errors, duplicate/order
and replay tests pass. make check: 72 tests, exit 0. Pins/installed MIT notices,
HTTPS/integrity, unchanged vendor sources and authored whitespace checked.
Evidence: state/evidence/S00-017-auto-protocol-599343cd9e3f.md.
Next step belongs only to independent security reviewer. Read source/test/ADR,
executor diff/message and README. Use the existing isolated runtime at
/private/tmp/luma-stack-runtime/node-v24.21.0-darwin-arm64/bin (or verified exact
vendor runtime), clean npm ci with a new cache and npm test using --prefix
experiments/0002-xmpp-library/sasl-validation. Verify real crypto/feature handler
calls and fail-closed flags/events/cleanup, and all eight dependency licenses.
Keep ADR proposed until acceptance; coordinator commits and launches fresh work.
Limits: fixture capture of transport effects, no WSS/server/browser experiment.
Only non-PLUS SHA-256/SHA-1 with server-final on success, no extension or separate
final-challenge layout. Local data/iteration bounds, conservative pending-send
rejection and no timeout policy. No constant-time/browser-crypto/advisory audit.
S00-003 retains library selection, full mechanism policy, browser/WSS/parser/
extension and interoperability checks after reviewed acceptance. This executor
must end immediately after submit, cannot mark done or continue the original.
+31 -4
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"revision": 38,
"revision": 40,
"tasks": {
"S00-001": {
"status": "done",
@@ -1221,10 +1221,14 @@
"completed_at": "2026-10-14T18:58:04+00:00"
},
"S00-017": {
"status": "todo",
"owner": null,
"status": "review",
"owner": "auto-protocol-599343cd9e3f",
"external_wait": false,
"reason": null
"reason": null,
"started_at": "2026-10-14T19:09:24+00:00",
"updated_at": "2026-10-14T19:17:46+00:00",
"evidence": "state/evidence/S00-017-auto-protocol-599343cd9e3f.md",
"handoff": "state/handoffs/S00-017.md"
}
},
"events": [
@@ -1716,6 +1720,29 @@
"from": "in_progress",
"to": "blocked",
"reason": "StanzaJS 12.22.1 SASL feature accepts missing/forged server-final without processSuccess/finalize, including success before challenge/proof. Safe SCRAM cannot be attested by S00-003 until this authentication boundary has a reviewed solution."
},
{
"revision": 39,
"at": "2026-10-14T19:09:24+00:00",
"action": "claim",
"task": "S00-017",
"agent": "auto-protocol-599343cd9e3f",
"role": "protocol",
"from": "todo",
"to": "in_progress",
"reason": null
},
{
"revision": 40,
"at": "2026-10-14T19:17:46+00:00",
"action": "submit",
"task": "S00-017",
"agent": "auto-protocol-599343cd9e3f",
"role": null,
"from": "in_progress",
"to": "review",
"reason": null,
"evidence": "state/evidence/S00-017-auto-protocol-599343cd9e3f.md"
}
],
"retired_agents": [