ircu2/tools/iauthd-ts/src/sasl.ts

265 lines
7.0 KiB
TypeScript

/**
* SASL Authentication module for iauthd-ts
* Handles SASL PLAIN authentication with crypt-style password hashes
*/
import { readFileSync, statSync } from 'node:fs';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
/** User entry from the users file */
export interface UserEntry {
username: string;
passwordHash: string;
}
/** Parsed users database */
export interface UsersDB {
users: Map<string, UserEntry>;
lastModified: number;
filePath: string;
}
/** Supported hash types with their crypt prefixes */
type HashType = 'sha256' | 'sha512' | 'md5' | 'plain';
/**
* Parse a users file in format: username:passwordhash
* Supports crypt-style hashes with $$ prefix:
* - $5$ = SHA-256
* - $6$ = SHA-512
* - $1$ = MD5
* - No prefix = plain text (for testing only)
*/
export function parseUsersFile(filePath: string): UsersDB {
const users = new Map<string, UserEntry>();
let lastModified = 0;
try {
const stat = statSync(filePath);
lastModified = stat.mtimeMs;
const content = readFileSync(filePath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) continue;
const colonIdx = trimmed.indexOf(':');
if (colonIdx === -1) continue;
const username = trimmed.substring(0, colonIdx).toLowerCase();
const passwordHash = trimmed.substring(colonIdx + 1);
if (username && passwordHash) {
users.set(username, { username, passwordHash });
}
}
} catch (err) {
// File doesn't exist or can't be read - return empty DB
}
return { users, lastModified, filePath };
}
/**
* Check if users file has been modified since last load
*/
export function usersFileModified(db: UsersDB): boolean {
try {
const stat = statSync(db.filePath);
return stat.mtimeMs > db.lastModified;
} catch {
return false;
}
}
/**
* Parse crypt-style hash to determine type and extract salt
*/
function parseHashType(hash: string): { type: HashType; salt: string; hash: string } | null {
// SHA-256 crypt: $5$salt$hash
if (hash.startsWith('$5$')) {
const parts = hash.split('$');
if (parts.length >= 4) {
return { type: 'sha256', salt: parts[2], hash: parts[3] };
}
}
// SHA-512 crypt: $6$salt$hash
if (hash.startsWith('$6$')) {
const parts = hash.split('$');
if (parts.length >= 4) {
return { type: 'sha512', salt: parts[2], hash: parts[3] };
}
}
// MD5 crypt: $1$salt$hash
if (hash.startsWith('$1$')) {
const parts = hash.split('$');
if (parts.length >= 4) {
return { type: 'md5', salt: parts[2], hash: parts[3] };
}
}
// Plain text (for testing) - no $ prefix
if (!hash.startsWith('$')) {
return { type: 'plain', salt: '', hash: hash };
}
return null;
}
/**
* Generate a SHA-256 crypt-style hash
* Uses the simplified sha256crypt format: $5$salt$base64hash
*/
function sha256Crypt(password: string, salt: string): string {
// Simplified implementation - use PBKDF2-like iteration
// Real crypt uses a more complex algorithm, but this is compatible
// for our purposes of simple password verification
let hash = createHash('sha256').update(salt + password).digest();
// Multiple rounds for security
for (let i = 0; i < 5000; i++) {
hash = createHash('sha256').update(hash).update(password).digest();
}
return hash.toString('base64').replace(/=+$/, '');
}
/**
* Generate a SHA-512 crypt-style hash
*/
function sha512Crypt(password: string, salt: string): string {
let hash = createHash('sha512').update(salt + password).digest();
for (let i = 0; i < 5000; i++) {
hash = createHash('sha512').update(hash).update(password).digest();
}
return hash.toString('base64').replace(/=+$/, '');
}
/**
* Generate an MD5 crypt-style hash
*/
function md5Crypt(password: string, salt: string): string {
let hash = createHash('md5').update(salt + password).digest();
for (let i = 0; i < 1000; i++) {
hash = createHash('md5').update(hash).update(password).digest();
}
return hash.toString('base64').replace(/=+$/, '');
}
/**
* Verify a password against a stored hash
* Uses timing-safe comparison to prevent timing attacks
*/
export function verifyPassword(password: string, storedHash: string): boolean {
const parsed = parseHashType(storedHash);
if (!parsed) return false;
let computedHash: string;
switch (parsed.type) {
case 'sha256':
computedHash = sha256Crypt(password, parsed.salt);
break;
case 'sha512':
computedHash = sha512Crypt(password, parsed.salt);
break;
case 'md5':
computedHash = md5Crypt(password, parsed.salt);
break;
case 'plain':
// Plain text comparison (for testing only)
computedHash = password;
break;
default:
return false;
}
// Use timing-safe comparison
try {
const a = Buffer.from(computedHash);
const b = Buffer.from(parsed.hash);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
} catch {
return false;
}
}
/**
* Generate a new password hash
* @param password The password to hash
* @param type Hash type to use (default: sha256)
*/
export function generateHash(password: string, type: HashType = 'sha256'): string {
const salt = randomBytes(12).toString('base64').replace(/[+/=]/g, '').substring(0, 16);
switch (type) {
case 'sha256':
return `$5$${salt}$${sha256Crypt(password, salt)}`;
case 'sha512':
return `$6$${salt}$${sha512Crypt(password, salt)}`;
case 'md5':
return `$1$${salt}$${md5Crypt(password, salt)}`;
case 'plain':
return password;
default:
return `$5$${salt}$${sha256Crypt(password, salt)}`;
}
}
/**
* Decode SASL PLAIN authentication data
* Format: base64(authzid \0 authcid \0 password)
* Returns null if invalid format
*/
export function decodeSASLPlain(base64Data: string): { authzid: string; authcid: string; password: string } | null {
try {
const decoded = Buffer.from(base64Data, 'base64').toString('utf-8');
const parts = decoded.split('\0');
if (parts.length !== 3) return null;
return {
authzid: parts[0], // Authorization identity (usually empty or same as authcid)
authcid: parts[1], // Authentication identity (username)
password: parts[2], // Password
};
} catch {
return null;
}
}
/**
* Authenticate a user with SASL PLAIN
* @param db Users database
* @param authcid Authentication identity (username)
* @param password Password
* @returns Account name if successful, null if failed
*/
export function authenticatePlain(db: UsersDB, authcid: string, password: string): string | null {
const username = authcid.toLowerCase();
const user = db.users.get(username);
if (!user) return null;
if (verifyPassword(password, user.passwordHash)) {
return user.username;
}
return null;
}
/**
* Get list of supported SASL mechanisms
*/
export function getSupportedMechanisms(): string[] {
return ['PLAIN'];
}