Changes: Add an isolated pinned mechanism wrapper using Stanza's public constructor and factory APIs, preserve the SCRAM-SHA-512 wire name and fail-closed final guard, and document the proposed ADR, browser bundle evidence, independent Scramp vectors, notices and limits. Leave parent selection, dependency experiments, specs and shared contracts unchanged. Validation: Fresh-cache npm ci and Vite browser build pass; 87 focused tests pass, including RFC HMAC vectors, original defect reproduction, independent full SHA-512 exchanges and rejected invalid finals/order/reset paths. Hashed Scramp oracle fixture comparison passes. Dependency check verifies 49 locked entries, 25 installed pins/licenses and 340 unmodified Stanza files. make check passes 72 tooling tests. Native browsers, WSS/live server interoperability and production readiness are untested; independent security review remains pending. Task: S00-019 Agent: auto-crypto-c68801170fa4 Role: crypto Action: claim Status: review Task title: Validate browser-compatible SCRAM-SHA-512 through a public mechanism API
112 lines
4.3 KiB
JavaScript
112 lines
4.3 KiB
JavaScript
// Isolated S00-019 copy of the reviewed S00-017 ordering guard.
|
|
import {registerSha512} from './sha512-mechanism.mjs';
|
|
// No production transport, credential persistence, or channel binding is provided.
|
|
const SUPPORTED = new Set(['SCRAM-SHA-512']);
|
|
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);
|
|
if (name !== 'SCRAM-SHA-512') throw new Error('Unsupported mechanism');
|
|
const length = 88;
|
|
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) {
|
|
registerSha512(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();
|
|
}
|
|
});
|
|
}
|