diff --git a/docs/decisions/0002-xmpp-sasl-validation.md b/docs/decisions/0002-xmpp-sasl-validation.md
new file mode 100644
index 0000000..5b50d30
--- /dev/null
+++ b/docs/decisions/0002-xmpp-sasl-validation.md
@@ -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.
diff --git a/experiments/0002-xmpp-library/sasl-validation/.npmrc b/experiments/0002-xmpp-library/sasl-validation/.npmrc
new file mode 100644
index 0000000..d253cac
--- /dev/null
+++ b/experiments/0002-xmpp-library/sasl-validation/.npmrc
@@ -0,0 +1,3 @@
+engine-strict=true
+strict-ssl=true
+registry=https://registry.npmjs.org/
diff --git a/experiments/0002-xmpp-library/sasl-validation/.nvmrc b/experiments/0002-xmpp-library/sasl-validation/.nvmrc
new file mode 100644
index 0000000..df6ae33
--- /dev/null
+++ b/experiments/0002-xmpp-library/sasl-validation/.nvmrc
@@ -0,0 +1 @@
+24.21.0
diff --git a/experiments/0002-xmpp-library/sasl-validation/LICENSE_INVENTORY.md b/experiments/0002-xmpp-library/sasl-validation/LICENSE_INVENTORY.md
new file mode 100644
index 0000000..8c0c891
--- /dev/null
+++ b/experiments/0002-xmpp-library/sasl-validation/LICENSE_INVENTORY.md
@@ -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 |
diff --git a/experiments/0002-xmpp-library/sasl-validation/README.md b/experiments/0002-xmpp-library/sasl-validation/README.md
new file mode 100644
index 0000000..1972b10
--- /dev/null
+++ b/experiments/0002-xmpp-library/sasl-validation/README.md
@@ -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.
diff --git a/experiments/0002-xmpp-library/sasl-validation/THIRD_PARTY_NOTICES.md b/experiments/0002-xmpp-library/sasl-validation/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..fad313e
--- /dev/null
+++ b/experiments/0002-xmpp-library/sasl-validation/THIRD_PARTY_NOTICES.md
@@ -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
+
+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
+
+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
+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.
+```
diff --git a/experiments/0002-xmpp-library/sasl-validation/package-lock.json b/experiments/0002-xmpp-library/sasl-validation/package-lock.json
new file mode 100644
index 0000000..431e13e
--- /dev/null
+++ b/experiments/0002-xmpp-library/sasl-validation/package-lock.json
@@ -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
+ }
+ }
+ }
+ }
+}
diff --git a/experiments/0002-xmpp-library/sasl-validation/package.json b/experiments/0002-xmpp-library/sasl-validation/package.json
new file mode 100644
index 0000000..24f09a5
--- /dev/null
+++ b/experiments/0002-xmpp-library/sasl-validation/package.json
@@ -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"}
+}
diff --git a/experiments/0002-xmpp-library/sasl-validation/test/fixtures.mjs b/experiments/0002-xmpp-library/sasl-validation/test/fixtures.mjs
new file mode 100644
index 0000000..97a9f75
--- /dev/null
+++ b/experiments/0002-xmpp-library/sasl-validation/test/fixtures.mjs
@@ -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='
+ }
+];
diff --git a/experiments/0002-xmpp-library/sasl-validation/test/integration.test.mjs b/experiments/0002-xmpp-library/sasl-validation/test/integration.test.mjs
new file mode 100644
index 0000000..bbd6880
--- /dev/null
+++ b/experiments/0002-xmpp-library/sasl-validation/test/integration.test.mjs
@@ -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);
+});
diff --git a/experiments/0002-xmpp-library/sasl-validation/validated-sasl.mjs b/experiments/0002-xmpp-library/sasl-validation/validated-sasl.mjs
new file mode 100644
index 0000000..2ea9d99
--- /dev/null
+++ b/experiments/0002-xmpp-library/sasl-validation/validated-sasl.mjs
@@ -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();
+ }
+ });
+}
diff --git a/planning/TASK_MATRIX.md b/planning/TASK_MATRIX.md
index 341dfb1..55cd1b1 100644
--- a/planning/TASK_MATRIX.md
+++ b/planning/TASK_MATRIX.md
@@ -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 |
diff --git a/planning/stages/S00.md b/planning/stages/S00.md
index 495e05a..819a762 100644
--- a/planning/stages/S00.md
+++ b/planning/stages/S00.md
@@ -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.
## Граница этапа
diff --git a/planning/task_matrix.csv b/planning/task_matrix.csv
index e84b93c..dd330e8 100644
--- a/planning/task_matrix.csv
+++ b/planning/task_matrix.csv
@@ -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
diff --git a/state/commit_messages/auto-protocol-599343cd9e3f.txt b/state/commit_messages/auto-protocol-599343cd9e3f.txt
new file mode 100644
index 0000000..59d1893
--- /dev/null
+++ b/state/commit_messages/auto-protocol-599343cd9e3f.txt
@@ -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.
diff --git a/state/evidence/S00-017-auto-protocol-599343cd9e3f.md b/state/evidence/S00-017-auto-protocol-599343cd9e3f.md
new file mode 100644
index 0000000..17b7abb
--- /dev/null
+++ b/state/evidence/S00-017-auto-protocol-599343cd9e3f.md
@@ -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.
diff --git a/state/handoffs/S00-017.md b/state/handoffs/S00-017.md
new file mode 100644
index 0000000..9eb55fe
--- /dev/null
+++ b/state/handoffs/S00-017.md
@@ -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.
diff --git a/state/tasks.json b/state/tasks.json
index 136a0a3..4db221a 100644
--- a/state/tasks.json
+++ b/state/tasks.json
@@ -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": [