556 lines
16 KiB
TypeScript
556 lines
16 KiB
TypeScript
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
import * as net from 'net';
|
|
import * as tls from 'tls';
|
|
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
|
|
|
const TEST_PORT = 2525;
|
|
|
|
let testServer: ITestServer;
|
|
const TEST_TIMEOUT = 30000;
|
|
|
|
tap.test('TLS Ciphers - should advertise STARTTLS for cipher negotiation', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
// Start test server
|
|
testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true });
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
try {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', () => resolve());
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
// Get banner
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Send EHLO
|
|
socket.write('EHLO testhost\r\n');
|
|
|
|
const ehloResponse = await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
});
|
|
|
|
// Check for STARTTLS support
|
|
const supportsStarttls = ehloResponse.includes('250-STARTTLS') || ehloResponse.includes('250 STARTTLS');
|
|
console.log('STARTTLS supported:', supportsStarttls);
|
|
|
|
if (supportsStarttls) {
|
|
console.log('Server supports STARTTLS - cipher negotiation available');
|
|
} else {
|
|
console.log('Server does not advertise STARTTLS - direct TLS connections may be required');
|
|
}
|
|
|
|
// Clean up
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
|
|
// Either behavior is acceptable
|
|
expect(true).toEqual(true);
|
|
|
|
} finally {
|
|
await stopTestServer(testServer);
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('TLS Ciphers - should negotiate secure cipher suites via STARTTLS', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
// Start test server
|
|
testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true });
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
try {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', () => resolve());
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
// Get banner
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Send EHLO
|
|
socket.write('EHLO testhost\r\n');
|
|
|
|
const ehloResponse = await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
});
|
|
|
|
// Check for STARTTLS
|
|
if (!ehloResponse.includes('STARTTLS')) {
|
|
console.log('Server does not support STARTTLS - skipping cipher test');
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
done.resolve();
|
|
return;
|
|
}
|
|
|
|
// Send STARTTLS
|
|
socket.write('STARTTLS\r\n');
|
|
|
|
const starttlsResponse = await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
expect(starttlsResponse).toInclude('220');
|
|
|
|
// Upgrade to TLS
|
|
const tlsSocket = tls.connect({
|
|
socket: socket,
|
|
servername: 'localhost',
|
|
rejectUnauthorized: false
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
tlsSocket.once('secureConnect', () => resolve());
|
|
tlsSocket.once('error', reject);
|
|
});
|
|
|
|
// Get cipher information
|
|
const cipher = tlsSocket.getCipher();
|
|
console.log('Negotiated cipher suite:');
|
|
console.log('- Name:', cipher.name);
|
|
console.log('- Standard name:', cipher.standardName);
|
|
console.log('- Version:', cipher.version);
|
|
|
|
// Check cipher security
|
|
const cipherSecurity = checkCipherSecurity(cipher);
|
|
console.log('Cipher security analysis:', cipherSecurity);
|
|
|
|
expect(cipher.name).toBeDefined();
|
|
expect(cipherSecurity.secure).toEqual(true);
|
|
|
|
// Clean up
|
|
tlsSocket.write('QUIT\r\n');
|
|
tlsSocket.end();
|
|
|
|
} finally {
|
|
await stopTestServer(testServer);
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('TLS Ciphers - should reject weak cipher suites', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
// Start test server
|
|
testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true });
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
try {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', () => resolve());
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
// Get banner
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Send EHLO
|
|
socket.write('EHLO testhost\r\n');
|
|
|
|
const ehloResponse = await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
});
|
|
|
|
// Check for STARTTLS
|
|
if (!ehloResponse.includes('STARTTLS')) {
|
|
console.log('Server does not support STARTTLS - skipping weak cipher test');
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
done.resolve();
|
|
return;
|
|
}
|
|
|
|
// Send STARTTLS
|
|
socket.write('STARTTLS\r\n');
|
|
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Try to connect with weak ciphers only
|
|
const weakCiphers = [
|
|
'DES-CBC3-SHA',
|
|
'RC4-MD5',
|
|
'RC4-SHA',
|
|
'NULL-SHA',
|
|
'EXPORT-DES40-CBC-SHA'
|
|
];
|
|
|
|
console.log('Testing connection with weak ciphers only...');
|
|
|
|
const connectionResult = await new Promise<{success: boolean, error?: string}>((resolve) => {
|
|
const tlsSocket = tls.connect({
|
|
socket: socket,
|
|
servername: 'localhost',
|
|
rejectUnauthorized: false,
|
|
ciphers: weakCiphers.join(':')
|
|
});
|
|
|
|
tlsSocket.once('secureConnect', () => {
|
|
// If connection succeeds, server accepts weak ciphers
|
|
const cipher = tlsSocket.getCipher();
|
|
tlsSocket.destroy();
|
|
resolve({
|
|
success: true,
|
|
error: `Server accepted weak cipher: ${cipher.name}`
|
|
});
|
|
});
|
|
|
|
tlsSocket.once('error', (err) => {
|
|
// Connection failed - good, server rejects weak ciphers
|
|
resolve({
|
|
success: false,
|
|
error: err.message
|
|
});
|
|
});
|
|
|
|
setTimeout(() => {
|
|
tlsSocket.destroy();
|
|
resolve({
|
|
success: false,
|
|
error: 'Connection timeout'
|
|
});
|
|
}, 5000);
|
|
});
|
|
|
|
if (!connectionResult.success) {
|
|
console.log('Good: Server rejected weak ciphers');
|
|
} else {
|
|
console.log('Warning:', connectionResult.error);
|
|
}
|
|
|
|
// Either behavior is logged - some servers may support legacy ciphers
|
|
expect(true).toEqual(true);
|
|
|
|
} finally {
|
|
await stopTestServer(testServer);
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('TLS Ciphers - should support forward secrecy', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
// Start test server
|
|
testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true });
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
try {
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', () => resolve());
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
// Get banner
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Send EHLO
|
|
socket.write('EHLO testhost\r\n');
|
|
|
|
const ehloResponse = await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
});
|
|
|
|
// Check for STARTTLS
|
|
if (!ehloResponse.includes('STARTTLS')) {
|
|
console.log('Server does not support STARTTLS - skipping forward secrecy test');
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
done.resolve();
|
|
return;
|
|
}
|
|
|
|
// Send STARTTLS
|
|
socket.write('STARTTLS\r\n');
|
|
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Prefer ciphers with forward secrecy (ECDHE, DHE)
|
|
const forwardSecrecyCiphers = [
|
|
'ECDHE-RSA-AES128-GCM-SHA256',
|
|
'ECDHE-RSA-AES256-GCM-SHA384',
|
|
'ECDHE-ECDSA-AES128-GCM-SHA256',
|
|
'ECDHE-ECDSA-AES256-GCM-SHA384',
|
|
'DHE-RSA-AES128-GCM-SHA256',
|
|
'DHE-RSA-AES256-GCM-SHA384'
|
|
];
|
|
|
|
const tlsSocket = tls.connect({
|
|
socket: socket,
|
|
servername: 'localhost',
|
|
rejectUnauthorized: false,
|
|
ciphers: forwardSecrecyCiphers.join(':')
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
tlsSocket.once('secureConnect', () => resolve());
|
|
tlsSocket.once('error', reject);
|
|
setTimeout(() => reject(new Error('TLS connection timeout')), 5000);
|
|
});
|
|
|
|
const cipher = tlsSocket.getCipher();
|
|
console.log('Forward secrecy cipher negotiated:', cipher.name);
|
|
|
|
// Check if cipher provides forward secrecy
|
|
const hasForwardSecrecy = cipher.name.includes('ECDHE') || cipher.name.includes('DHE');
|
|
console.log('Forward secrecy:', hasForwardSecrecy ? 'YES' : 'NO');
|
|
|
|
if (hasForwardSecrecy) {
|
|
console.log('Good: Server supports forward secrecy');
|
|
} else {
|
|
console.log('Warning: Negotiated cipher does not provide forward secrecy');
|
|
}
|
|
|
|
// Clean up
|
|
tlsSocket.write('QUIT\r\n');
|
|
tlsSocket.end();
|
|
|
|
// Forward secrecy is recommended but not required
|
|
expect(true).toEqual(true);
|
|
|
|
} finally {
|
|
await stopTestServer(testServer);
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
tap.test('TLS Ciphers - should list all supported ciphers', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
// Start test server
|
|
testServer = await startTestServer({ port: TEST_PORT, tlsEnabled: true });
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
try {
|
|
// Get list of ciphers supported by Node.js
|
|
const supportedCiphers = tls.getCiphers();
|
|
console.log(`Node.js supports ${supportedCiphers.length} cipher suites`);
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once('connect', () => resolve());
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
// Get banner
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Send EHLO
|
|
socket.write('EHLO testhost\r\n');
|
|
|
|
const ehloResponse = await new Promise<string>((resolve) => {
|
|
let data = '';
|
|
const handler = (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
|
|
socket.removeListener('data', handler);
|
|
resolve(data);
|
|
}
|
|
};
|
|
socket.on('data', handler);
|
|
});
|
|
|
|
// Check for STARTTLS
|
|
if (!ehloResponse.includes('STARTTLS')) {
|
|
console.log('Server does not support STARTTLS - skipping cipher list test');
|
|
socket.write('QUIT\r\n');
|
|
socket.end();
|
|
done.resolve();
|
|
return;
|
|
}
|
|
|
|
// Send STARTTLS
|
|
socket.write('STARTTLS\r\n');
|
|
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
// Test connection with default ciphers
|
|
const tlsSocket = tls.connect({
|
|
socket: socket,
|
|
servername: 'localhost',
|
|
rejectUnauthorized: false
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
tlsSocket.once('secureConnect', () => resolve());
|
|
tlsSocket.once('error', reject);
|
|
setTimeout(() => reject(new Error('TLS connection timeout')), 5000);
|
|
});
|
|
|
|
const negotiatedCipher = tlsSocket.getCipher();
|
|
console.log('\nServer selected cipher:', negotiatedCipher.name);
|
|
|
|
// Categorize the cipher
|
|
const categories = {
|
|
'AEAD': negotiatedCipher.name.includes('GCM') || negotiatedCipher.name.includes('CCM') || negotiatedCipher.name.includes('POLY1305'),
|
|
'Forward Secrecy': negotiatedCipher.name.includes('ECDHE') || negotiatedCipher.name.includes('DHE'),
|
|
'Strong Encryption': negotiatedCipher.name.includes('AES') && (negotiatedCipher.name.includes('128') || negotiatedCipher.name.includes('256'))
|
|
};
|
|
|
|
console.log('Cipher properties:');
|
|
Object.entries(categories).forEach(([property, value]) => {
|
|
console.log(`- ${property}: ${value ? 'YES' : 'NO'}`);
|
|
});
|
|
|
|
// Clean up
|
|
tlsSocket.end();
|
|
|
|
expect(negotiatedCipher.name).toBeDefined();
|
|
|
|
} finally {
|
|
await stopTestServer(testServer);
|
|
done.resolve();
|
|
}
|
|
});
|
|
|
|
// Helper function to check cipher security
|
|
function checkCipherSecurity(cipher: any): {secure: boolean, reason?: string, recommendations?: string[]} {
|
|
if (!cipher || !cipher.name) {
|
|
return {
|
|
secure: false,
|
|
reason: 'No cipher information available'
|
|
};
|
|
}
|
|
|
|
const cipherName = cipher.name.toUpperCase();
|
|
const recommendations: string[] = [];
|
|
|
|
// Check for insecure ciphers
|
|
const insecureCiphers = ['NULL', 'EXPORT', 'DES', '3DES', 'RC4', 'MD5'];
|
|
|
|
for (const insecure of insecureCiphers) {
|
|
if (cipherName.includes(insecure)) {
|
|
return {
|
|
secure: false,
|
|
reason: `Insecure cipher detected: ${insecure} in ${cipherName}`,
|
|
recommendations: ['Use AEAD ciphers like AES-GCM or ChaCha20-Poly1305']
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check for recommended secure ciphers
|
|
const secureCiphers = [
|
|
'AES128-GCM', 'AES256-GCM', 'CHACHA20-POLY1305',
|
|
'AES128-CCM', 'AES256-CCM'
|
|
];
|
|
|
|
const hasSecureCipher = secureCiphers.some(secure =>
|
|
cipherName.includes(secure.replace('-', '_')) || cipherName.includes(secure)
|
|
);
|
|
|
|
if (hasSecureCipher) {
|
|
return {
|
|
secure: true,
|
|
recommendations: ['Cipher suite is considered secure']
|
|
};
|
|
}
|
|
|
|
// Check for acceptable but not ideal ciphers
|
|
if (cipherName.includes('AES') && !cipherName.includes('CBC')) {
|
|
return {
|
|
secure: true,
|
|
recommendations: ['Consider upgrading to AEAD ciphers for better security']
|
|
};
|
|
}
|
|
|
|
// Check for weak but sometimes acceptable ciphers
|
|
if (cipherName.includes('AES') && cipherName.includes('CBC')) {
|
|
recommendations.push('CBC mode ciphers are vulnerable to padding oracle attacks');
|
|
recommendations.push('Consider upgrading to GCM or other AEAD modes');
|
|
return {
|
|
secure: true, // Still acceptable but not ideal
|
|
recommendations: recommendations
|
|
};
|
|
}
|
|
|
|
// Default to secure if it's a modern cipher we don't recognize
|
|
return {
|
|
secure: true,
|
|
recommendations: [`Unknown cipher ${cipherName} - verify security manually`]
|
|
};
|
|
}
|
|
|
|
export default tap.start(); |