Implement Phase 1.2: Database with encryption at rest and core infrastructure
Co-authored-by: southseact-3d <217551146+southseact-3d@users.noreply.github.com>
This commit is contained in:
209
chat/src/utils/encryption.js
Normal file
209
chat/src/utils/encryption.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Field-level encryption utilities using AES-256-GCM
|
||||
* Provides authenticated encryption for sensitive data
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16; // 128 bits for GCM
|
||||
const SALT_LENGTH = 32;
|
||||
const TAG_LENGTH = 16; // 128 bits authentication tag
|
||||
const KEY_LENGTH = 32; // 256 bits
|
||||
const PBKDF2_ITERATIONS = 100000;
|
||||
|
||||
let masterKey = null;
|
||||
|
||||
/**
|
||||
* Initialize encryption with master key
|
||||
* @param {string} key - Master encryption key (hex string)
|
||||
*/
|
||||
function initEncryption(key) {
|
||||
if (!key || typeof key !== 'string') {
|
||||
throw new Error('Master encryption key is required');
|
||||
}
|
||||
|
||||
// Key should be at least 64 hex characters (32 bytes)
|
||||
if (key.length < 64) {
|
||||
throw new Error('Master encryption key must be at least 64 hex characters (32 bytes)');
|
||||
}
|
||||
|
||||
masterKey = Buffer.from(key.slice(0, 64), 'hex');
|
||||
console.log('✅ Encryption initialized with master key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive encryption key from master key and salt using PBKDF2
|
||||
* @param {Buffer} salt - Salt for key derivation
|
||||
* @returns {Buffer} Derived key
|
||||
*/
|
||||
function deriveKey(salt) {
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption not initialized. Call initEncryption() first.');
|
||||
}
|
||||
|
||||
return crypto.pbkdf2Sync(masterKey, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a string value
|
||||
* @param {string} plaintext - Value to encrypt
|
||||
* @returns {string} Encrypted value with format: salt:iv:tag:ciphertext (all hex encoded)
|
||||
*/
|
||||
function encrypt(plaintext) {
|
||||
if (!plaintext) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption not initialized. Call initEncryption() first.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate random salt and IV
|
||||
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
// Derive key from master key and salt
|
||||
const key = deriveKey(salt);
|
||||
|
||||
// Create cipher
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
// Encrypt
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final()
|
||||
]);
|
||||
|
||||
// Get authentication tag
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Combine: salt:iv:tag:ciphertext
|
||||
return [
|
||||
salt.toString('hex'),
|
||||
iv.toString('hex'),
|
||||
tag.toString('hex'),
|
||||
encrypted.toString('hex')
|
||||
].join(':');
|
||||
} catch (error) {
|
||||
console.error('Encryption error:', error);
|
||||
throw new Error('Failed to encrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted string value
|
||||
* @param {string} ciphertext - Encrypted value with format: salt:iv:tag:ciphertext
|
||||
* @returns {string} Decrypted plaintext
|
||||
*/
|
||||
function decrypt(ciphertext) {
|
||||
if (!ciphertext) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption not initialized. Call initEncryption() first.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Split components
|
||||
const parts = ciphertext.split(':');
|
||||
if (parts.length !== 4) {
|
||||
throw new Error('Invalid encrypted data format');
|
||||
}
|
||||
|
||||
const [saltHex, ivHex, tagHex, encryptedHex] = parts;
|
||||
|
||||
// Convert from hex
|
||||
const salt = Buffer.from(saltHex, 'hex');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
const encrypted = Buffer.from(encryptedHex, 'hex');
|
||||
|
||||
// Derive key from master key and salt
|
||||
const key = deriveKey(salt);
|
||||
|
||||
// Create decipher
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
// Decrypt
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
console.error('Decryption error:', error);
|
||||
throw new Error('Failed to decrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a value using PBKDF2 (for tokens, not for encryption)
|
||||
* @param {string} value - Value to hash
|
||||
* @param {string} salt - Optional salt (hex string), will generate if not provided
|
||||
* @returns {Object} Object with hash and salt (both hex strings)
|
||||
*/
|
||||
function hashValue(value, salt = null) {
|
||||
if (!value) {
|
||||
throw new Error('Value is required for hashing');
|
||||
}
|
||||
|
||||
const saltBuffer = salt ? Buffer.from(salt, 'hex') : crypto.randomBytes(SALT_LENGTH);
|
||||
const hash = crypto.pbkdf2Sync(value, saltBuffer, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
|
||||
|
||||
return {
|
||||
hash: hash.toString('hex'),
|
||||
salt: saltBuffer.toString('hex')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a hashed value
|
||||
* @param {string} value - Value to verify
|
||||
* @param {string} hash - Expected hash (hex string)
|
||||
* @param {string} salt - Salt used for hashing (hex string)
|
||||
* @returns {boolean} True if match
|
||||
*/
|
||||
function verifyHash(value, hash, salt) {
|
||||
if (!value || !hash || !salt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = hashValue(value, salt);
|
||||
return crypto.timingSafeEqual(Buffer.from(result.hash, 'hex'), Buffer.from(hash, 'hex'));
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure random token
|
||||
* @param {number} bytes - Number of random bytes (default 32)
|
||||
* @returns {string} Random token (hex string)
|
||||
*/
|
||||
function generateToken(bytes = 32) {
|
||||
return crypto.randomBytes(bytes).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption is initialized
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isEncryptionInitialized() {
|
||||
return masterKey !== null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initEncryption,
|
||||
encrypt,
|
||||
decrypt,
|
||||
hashValue,
|
||||
verifyHash,
|
||||
generateToken,
|
||||
isEncryptionInitialized
|
||||
};
|
||||
254
chat/src/utils/tokenManager.js
Normal file
254
chat/src/utils/tokenManager.js
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Token Manager for JWT access tokens and refresh tokens
|
||||
* Implements secure session management with token rotation
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { hashValue, verifyHash, generateToken } = require('./encryption');
|
||||
|
||||
const ACCESS_TOKEN_TTL = 15 * 60; // 15 minutes in seconds
|
||||
const REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60; // 7 days in seconds
|
||||
const REFRESH_TOKEN_BYTES = 64; // 128 character hex string
|
||||
|
||||
let jwtSecret = null;
|
||||
|
||||
/**
|
||||
* Initialize token manager with JWT secret
|
||||
* @param {string} secret - JWT signing secret
|
||||
*/
|
||||
function initTokenManager(secret) {
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
throw new Error('JWT secret is required');
|
||||
}
|
||||
|
||||
jwtSecret = secret;
|
||||
console.log('✅ Token manager initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate device fingerprint from request
|
||||
* @param {Object} req - HTTP request object
|
||||
* @returns {string} Device fingerprint (32 character hex)
|
||||
*/
|
||||
function generateDeviceFingerprint(req) {
|
||||
const components = [
|
||||
req.headers['user-agent'] || '',
|
||||
req.headers['accept-language'] || '',
|
||||
req.ip || req.connection?.remoteAddress || '',
|
||||
req.headers['x-forwarded-for'] || ''
|
||||
];
|
||||
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(components.join('|'))
|
||||
.digest('hex')
|
||||
.substring(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT access token
|
||||
* @param {Object} payload - Token payload (userId, email, role, plan)
|
||||
* @param {Object} options - Token options
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
function generateAccessToken(payload, options = {}) {
|
||||
if (!jwtSecret) {
|
||||
throw new Error('Token manager not initialized');
|
||||
}
|
||||
|
||||
const jti = crypto.randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const tokenPayload = {
|
||||
jti,
|
||||
userId: payload.userId,
|
||||
email: payload.email,
|
||||
role: payload.role || 'user',
|
||||
plan: payload.plan || 'hobby',
|
||||
iat: now,
|
||||
exp: now + (options.ttl || ACCESS_TOKEN_TTL)
|
||||
};
|
||||
|
||||
return jwt.sign(tokenPayload, jwtSecret, {
|
||||
algorithm: 'HS256'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode JWT access token
|
||||
* @param {string} token - JWT token to verify
|
||||
* @returns {Object|null} Decoded token payload or null if invalid
|
||||
*/
|
||||
function verifyAccessToken(token) {
|
||||
if (!jwtSecret) {
|
||||
throw new Error('Token manager not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, jwtSecret, {
|
||||
algorithms: ['HS256']
|
||||
});
|
||||
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return { expired: true, error: 'Token expired' };
|
||||
}
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return { invalid: true, error: 'Invalid token' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token
|
||||
* @returns {Object} Object with token and tokenHash
|
||||
*/
|
||||
function generateRefreshToken() {
|
||||
const token = generateToken(REFRESH_TOKEN_BYTES);
|
||||
const { hash, salt } = hashValue(token);
|
||||
|
||||
return {
|
||||
token,
|
||||
tokenHash: `${salt}:${hash}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify refresh token against stored hash
|
||||
* @param {string} token - Refresh token to verify
|
||||
* @param {string} storedHash - Stored hash in format "salt:hash"
|
||||
* @returns {boolean} True if token matches hash
|
||||
*/
|
||||
function verifyRefreshToken(token, storedHash) {
|
||||
if (!token || !storedHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const [salt, hash] = storedHash.split(':');
|
||||
if (!salt || !hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return verifyHash(token, hash, salt);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token from Authorization header or cookie
|
||||
* @param {Object} req - HTTP request object
|
||||
* @param {string} cookieName - Name of the cookie containing token
|
||||
* @returns {string|null} Token or null
|
||||
*/
|
||||
function extractToken(req, cookieName = 'access_token') {
|
||||
// Check Authorization header first (Bearer token)
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Check cookie
|
||||
if (req.headers.cookie) {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
return cookies[cookieName] || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cookie header
|
||||
* @param {string} cookieHeader - Cookie header string
|
||||
* @returns {Object} Parsed cookies
|
||||
*/
|
||||
function parseCookies(cookieHeader) {
|
||||
const cookies = {};
|
||||
|
||||
if (!cookieHeader) {
|
||||
return cookies;
|
||||
}
|
||||
|
||||
cookieHeader.split(';').forEach(cookie => {
|
||||
const [name, ...rest] = cookie.split('=');
|
||||
if (name && rest.length > 0) {
|
||||
cookies[name.trim()] = rest.join('=').trim();
|
||||
}
|
||||
});
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create secure cookie string
|
||||
* @param {string} name - Cookie name
|
||||
* @param {string} value - Cookie value
|
||||
* @param {Object} options - Cookie options
|
||||
* @returns {string} Set-Cookie header value
|
||||
*/
|
||||
function createSecureCookie(name, value, options = {}) {
|
||||
const parts = [`${name}=${value}`];
|
||||
|
||||
if (options.maxAge) {
|
||||
parts.push(`Max-Age=${options.maxAge}`);
|
||||
}
|
||||
|
||||
if (options.path) {
|
||||
parts.push(`Path=${options.path}`);
|
||||
} else {
|
||||
parts.push('Path=/');
|
||||
}
|
||||
|
||||
if (options.httpOnly !== false) {
|
||||
parts.push('HttpOnly');
|
||||
}
|
||||
|
||||
if (options.secure) {
|
||||
parts.push('Secure');
|
||||
}
|
||||
|
||||
if (options.sameSite) {
|
||||
parts.push(`SameSite=${options.sameSite}`);
|
||||
} else {
|
||||
parts.push('SameSite=Strict');
|
||||
}
|
||||
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token TTL values
|
||||
* @returns {Object} Object with accessTokenTTL and refreshTokenTTL
|
||||
*/
|
||||
function getTokenTTL() {
|
||||
return {
|
||||
accessTokenTTL: ACCESS_TOKEN_TTL,
|
||||
refreshTokenTTL: REFRESH_TOKEN_TTL
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token manager is initialized
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isTokenManagerInitialized() {
|
||||
return jwtSecret !== null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initTokenManager,
|
||||
generateDeviceFingerprint,
|
||||
generateAccessToken,
|
||||
verifyAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyRefreshToken,
|
||||
extractToken,
|
||||
createSecureCookie,
|
||||
parseCookies,
|
||||
getTokenTTL,
|
||||
isTokenManagerInitialized
|
||||
};
|
||||
Reference in New Issue
Block a user