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
109 lines
4.3 KiB
JavaScript
109 lines
4.3 KiB
JavaScript
// 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();
|
|
}
|
|
});
|
|
}
|