Files
PrivateChat/server/dist/index.js
2026-03-09 19:35:08 +01:00

1027 lines
37 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { TextEncoder } from 'node:util';
import { DatabaseSync } from 'node:sqlite';
import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import fastifyStatic from '@fastify/static';
import websocket from '@fastify/websocket';
import dotenv from 'dotenv';
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
import Fastify from 'fastify';
import { Redis } from 'ioredis';
import { z } from 'zod';
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
const registerSchema = z.object({
username: z.string().trim().min(3).max(32).regex(/^[a-zA-Z0-9_-]+$/),
displayName: z.string().trim().min(2).max(48).optional(),
password: z.string().min(8).max(128),
});
const loginSchema = z.object({
username: z.string().trim().min(3).max(32),
password: z.string().min(8).max(128),
});
const accessKeyLabelSchema = z.object({
label: z.string().trim().min(1).max(64).optional(),
});
const verifyAccessKeySchema = z.object({
credential: z.custom(),
});
const accessKeyAuthenticationSchema = z.object({
username: z.string().trim().min(3).max(32).optional(),
});
const verifyAccessKeyAuthenticationSchema = z.object({
attemptId: z.string().min(1),
credential: z.custom(),
});
const approvePendingUserParamsSchema = z.object({
userId: z.string().min(1),
});
const wsQuerySchema = z.object({
token: z.string().min(1),
});
const signalMessageSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('signal'),
to: z.string().min(1),
signal: z.discriminatedUnion('type', [
z.object({
type: z.literal('sdp'),
description: z.object({
type: z.enum(['offer', 'answer', 'pranswer', 'rollback']),
sdp: z.string().optional(),
}),
}),
z.object({
type: z.literal('ice-candidate'),
candidate: z.object({
candidate: z.string().optional(),
sdpMid: z.string().nullable().optional(),
sdpMLineIndex: z.number().nullable().optional(),
usernameFragment: z.string().nullable().optional(),
}),
}),
]),
}),
]);
const app = Fastify({ logger: true });
const approvalAdminUsername = 'ladparis';
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
const sqlitePath = resolveStoragePath(process.env.SQLITE_PATH ?? path.join(dataDirectory, 'privatechat.sqlite'));
const masterKeyPath = resolveStoragePath(process.env.PRIVATECHAT_MASTER_KEY_PATH ?? path.join(dataDirectory, 'master.key'));
const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser');
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
const encryptionKey = deriveEncryptionKey(loadOrCreateMasterKey(masterKeyPath));
const database = new DatabaseSync(sqlitePath);
database.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
encrypted_credentials TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
approved_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS app_secrets (
name TEXT PRIMARY KEY,
encrypted_value TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
credential_id TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
encrypted_registration TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
ensureUserApprovalColumns();
const createUserStatement = database.prepare(`
INSERT INTO users (id, username, display_name, encrypted_credentials, is_active, approved_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const selectUserByUsernameStatement = database.prepare(`
SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at
FROM users
WHERE username = ?
`);
const selectUserByIdStatement = database.prepare(`
SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at
FROM users
WHERE id = ?
`);
const selectPendingUsersStatement = database.prepare(`
SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at
FROM users
WHERE is_active = 0
ORDER BY created_at ASC
`);
const approveUserStatement = database.prepare(`
UPDATE users
SET is_active = 1, approved_at = ?
WHERE id = ? AND is_active = 0
`);
const updateUserCredentialsStatement = database.prepare(`
UPDATE users
SET encrypted_credentials = ?
WHERE id = ?
`);
const insertSecretStatement = database.prepare(`
INSERT INTO app_secrets (name, encrypted_value, created_at)
VALUES (?, ?, ?)
`);
const selectSecretStatement = database.prepare(`
SELECT encrypted_value
FROM app_secrets
WHERE name = ?
`);
const createAccessKeyStatement = database.prepare(`
INSERT INTO webauthn_credentials (id, user_id, credential_id, label, encrypted_registration, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
const selectAccessKeysByUserStatement = database.prepare(`
SELECT id, user_id, credential_id, label, encrypted_registration, created_at
FROM webauthn_credentials
WHERE user_id = ?
ORDER BY created_at DESC
`);
const selectAccessKeyByCredentialIdStatement = database.prepare(`
SELECT id, user_id, credential_id, label, encrypted_registration, created_at
FROM webauthn_credentials
WHERE credential_id = ?
`);
const updateAccessKeyStatement = database.prepare(`
UPDATE webauthn_credentials
SET encrypted_registration = ?
WHERE credential_id = ?
`);
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
const socketsByUserId = new Map();
await redis.ping();
await app.register(cors, {
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true,
credentials: false,
});
await app.register(jwt, {
secret: jwtSecret,
});
await app.register(websocket);
if (hasFrontendBuild) {
await app.register(fastifyStatic, {
root: frontendDistPath,
prefix: '/',
});
app.setNotFoundHandler((request, reply) => {
const requestPath = request.raw.url?.split('?')[0] ?? '/';
if (request.method !== 'GET' ||
requestPath === '/ws' ||
requestPath === '/api' ||
requestPath.startsWith('/api/')) {
return reply.code(404).send({ message: 'Not found.' });
}
return reply.type('text/html; charset=utf-8').send(fs.createReadStream(frontendIndexPath));
});
}
else {
app.log.warn({ frontendDistPath }, 'Angular frontend build not found. Build the client before serving it from the backend.');
}
app.get('/api/health', async () => ({ ok: true }));
app.post('/api/auth/register', async (request, reply) => {
const parsed = registerSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid registration payload.',
issues: parsed.error.flatten(),
});
}
const username = parsed.data.username.toLowerCase();
if (findUserByUsername(username)) {
return reply.code(409).send({ message: 'Username is already taken.' });
}
const user = createUser({
username,
displayName: parsed.data.displayName?.trim() || parsed.data.username,
password: parsed.data.password,
isActive: username === approvalAdminUsername,
});
if (!user.isActive) {
return reply.code(202).send({
pendingApproval: true,
message: `Account created. It must be approved by ${approvalAdminUsername} before you can sign in.`,
});
}
const session = await createSession(user.id);
return createAuthReply(user, session.sessionId);
});
app.post('/api/auth/login', async (request, reply) => {
const parsed = loginSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid login payload.',
issues: parsed.error.flatten(),
});
}
const user = findUserByUsername(parsed.data.username.toLowerCase());
if (!user || !verifyPassword(parsed.data.password, user.passwordHash)) {
return reply.code(401).send({ message: 'Invalid credentials.' });
}
if (!user.isActive) {
return reply.code(403).send({
message: `Your account is awaiting approval from ${approvalAdminUsername}.`,
});
}
const session = await createSession(user.id);
return createAuthReply(user, session.sessionId);
});
app.post('/api/webauthn/authenticate/options', async (request, reply) => {
const parsed = accessKeyAuthenticationSchema.safeParse(request.body ?? {});
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid access key sign-in payload.',
issues: parsed.error.flatten(),
});
}
const expectedOrigin = resolveWebAuthnOrigin(request);
const expectedRpId = process.env.WEBAUTHN_RP_ID ?? new URL(expectedOrigin).hostname;
const username = parsed.data.username?.trim().toLowerCase();
const user = username ? findUserByUsername(username) : null;
if (username && !user) {
return reply.code(400).send({ message: 'No access key is registered for that username.' });
}
if (user && !user.isActive) {
return reply.code(403).send({
message: `Your account is awaiting approval from ${approvalAdminUsername}.`,
});
}
const storedCredentials = user ? listStoredAccessKeys(user.id) : [];
if (username && storedCredentials.length === 0) {
return reply.code(400).send({ message: 'No access key is registered for that username.' });
}
const attemptId = crypto.randomUUID();
const options = await generateAuthenticationOptions({
rpID: expectedRpId,
userVerification: webAuthnUserVerification,
allowCredentials: storedCredentials.length > 0
? storedCredentials.map((credential) => ({
id: credential.credentialId,
transports: credential.transports,
}))
: undefined,
});
await redis.set(webAuthnAuthenticationKey(attemptId), JSON.stringify({
challenge: options.challenge,
expectedOrigin,
expectedRpId,
expectedUserId: user?.id ?? null,
}), 'EX', webAuthnChallengeTtlSeconds);
return {
attemptId,
expectedOrigin,
...options,
};
});
app.post('/api/webauthn/authenticate/verify', async (request, reply) => {
const parsed = verifyAccessKeyAuthenticationSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid access key sign-in verification payload.',
issues: parsed.error.flatten(),
});
}
const pending = await getPendingAuthentication(parsed.data.attemptId);
if (!pending) {
return reply.code(400).send({ message: 'Access key sign-in challenge expired.' });
}
try {
const storedAccessKey = findStoredAccessKeyByCredentialId(parsed.data.credential.id);
if (!storedAccessKey) {
return reply.code(401).send({ message: 'This access key is not registered.' });
}
if (!storedAccessKey.user.isActive) {
return reply.code(403).send({
message: `Your account is awaiting approval from ${approvalAdminUsername}.`,
});
}
if (pending.expectedUserId && storedAccessKey.user.id !== pending.expectedUserId) {
return reply.code(401).send({ message: 'This access key does not match the requested username.' });
}
const verification = await verifyAuthenticationResponse({
response: parsed.data.credential,
expectedChallenge: pending.challenge,
expectedOrigin: pending.expectedOrigin,
expectedRPID: pending.expectedRpId,
credential: {
id: storedAccessKey.registration.credentialId,
publicKey: Buffer.from(storedAccessKey.registration.publicKey, 'base64url'),
counter: storedAccessKey.registration.counter,
transports: storedAccessKey.registration.transports,
},
requireUserVerification: webAuthnUserVerification === 'required',
});
if (!verification.verified) {
return reply.code(401).send({ message: 'Access key sign-in could not be verified.' });
}
updateStoredAccessKey(storedAccessKey, {
counter: verification.authenticationInfo.newCounter,
deviceType: verification.authenticationInfo.credentialDeviceType,
backedUp: verification.authenticationInfo.credentialBackedUp,
});
const session = await createSession(storedAccessKey.user.id);
return createAuthReply(storedAccessKey.user, session.sessionId);
}
catch (error) {
app.log.warn({ err: error }, 'WebAuthn authentication verification failed');
return reply.code(400).send({
message: error instanceof Error
? `Access key sign-in could not be verified: ${error.message}`
: 'Access key sign-in could not be verified.',
});
}
finally {
await clearPendingAuthentication(parsed.data.attemptId);
}
});
app.get('/api/auth/session', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
return {
user: toPublicUser(authContext.user),
messageEncryptionKey: authContext.user.messageEncryptionKey,
};
});
app.get('/api/admin/pending-users', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
if (!isApprovalAdmin(authContext.user)) {
return reply.code(403).send({ message: 'Only ladparis can approve accounts.' });
}
return {
users: listPendingApprovalUsers(),
};
});
app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
if (!isApprovalAdmin(authContext.user)) {
return reply.code(403).send({ message: 'Only ladparis can approve accounts.' });
}
const parsed = approvePendingUserParamsSchema.safeParse(request.params);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid user approval request.',
issues: parsed.error.flatten(),
});
}
const approvedUser = approveUser(parsed.data.userId);
if (!approvedUser) {
return reply.code(404).send({ message: 'Pending user not found.' });
}
return {
user: toPublicUser(approvedUser),
};
});
app.post('/api/auth/logout', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
await destroySession(authContext.session.sessionId);
await clearPendingRegistration(authContext.session.sessionId);
closeSocketSession(authContext.user.id, authContext.session.sessionId);
return { ok: true };
});
app.get('/api/webauthn/credentials', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
return {
credentials: listAccessKeys(authContext.user.id),
};
});
app.post('/api/webauthn/register/options', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
const parsed = accessKeyLabelSchema.safeParse(request.body ?? {});
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid access key request payload.',
issues: parsed.error.flatten(),
});
}
const label = parsed.data.label?.trim() || defaultAccessKeyLabel();
const storedCredentials = listStoredAccessKeys(authContext.user.id);
const expectedOrigin = resolveWebAuthnOrigin(request);
const expectedRpId = process.env.WEBAUTHN_RP_ID ?? new URL(expectedOrigin).hostname;
const options = await generateRegistrationOptions({
rpName: webAuthnRpName,
rpID: expectedRpId,
userName: authContext.user.username,
userDisplayName: authContext.user.displayName,
userID: new TextEncoder().encode(authContext.user.id),
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: webAuthnUserVerification,
},
excludeCredentials: storedCredentials.map((credential) => ({
id: credential.credentialId,
transports: credential.transports,
})),
});
await redis.set(webAuthnRegistrationKey(authContext.session.sessionId), JSON.stringify({
challenge: options.challenge,
label,
expectedOrigin,
expectedRpId,
}), 'EX', webAuthnChallengeTtlSeconds);
return {
expectedOrigin,
...options,
};
});
app.post('/api/webauthn/register/verify', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
const parsed = verifyAccessKeySchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid access key verification payload.',
issues: parsed.error.flatten(),
});
}
const pending = await getPendingRegistration(authContext.session.sessionId);
if (!pending) {
return reply.code(400).send({ message: 'Access key registration challenge expired.' });
}
try {
const verification = await verifyRegistrationResponse({
response: parsed.data.credential,
expectedChallenge: pending.challenge,
expectedOrigin: pending.expectedOrigin,
expectedRPID: pending.expectedRpId,
requireUserVerification: webAuthnUserVerification === 'required',
});
if (!verification.verified || !verification.registrationInfo) {
return reply.code(400).send({ message: 'Access key registration could not be verified.' });
}
const registrationInfo = verification.registrationInfo;
persistAccessKey(authContext.user.id, {
credentialId: registrationInfo.credential.id,
label: pending.label,
publicKey: bytesToBase64Url(registrationInfo.credential.publicKey),
counter: registrationInfo.credential.counter,
transports: registrationInfo.credential.transports ?? [],
deviceType: registrationInfo.credentialDeviceType,
backedUp: registrationInfo.credentialBackedUp,
aaguid: registrationInfo.aaguid,
});
await clearPendingRegistration(authContext.session.sessionId);
return {
credential: listAccessKeys(authContext.user.id)[0],
};
}
catch (error) {
app.log.warn({ err: error }, 'WebAuthn registration verification failed');
return reply.code(400).send({
message: error instanceof Error
? `Access key registration could not be verified: ${error.message}`
: 'Access key registration could not be verified.',
});
}
});
app.get('/ws', { websocket: true }, (socket, request) => {
void openSocket(socket, request);
});
const port = Number(process.env.PORT ?? 16990);
await app.listen({ port, host: '0.0.0.0' });
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
async function openSocket(socket, request) {
const query = wsQuerySchema.safeParse(request.query);
if (!query.success) {
send(socket, { type: 'error', message: 'Missing token.' });
socket.close();
return;
}
const authContext = await authenticateToken(query.data.token);
if (!authContext) {
send(socket, { type: 'error', message: 'Authentication required.' });
socket.close();
return;
}
const userSockets = socketsByUserId.get(authContext.user.id) ?? new Map();
const isFirstConnection = userSockets.size === 0;
userSockets.set(authContext.session.sessionId, socket);
socketsByUserId.set(authContext.user.id, userSockets);
send(socket, {
type: 'presence',
self: toPublicUser(authContext.user),
peers: listOnlinePeers(authContext.user.id),
});
if (isFirstConnection) {
broadcastExcept(authContext.user.id, {
type: 'peer-joined',
peer: toPublicUser(authContext.user),
});
}
socket.on('message', (rawMessage) => {
void handleSocketMessage(authContext.user.id, authContext.session.sessionId, socket, rawMessage.toString());
});
socket.on('close', () => {
if (detachSocket(authContext.user.id, authContext.session.sessionId)) {
broadcastExcept(authContext.user.id, { type: 'peer-left', peerId: authContext.user.id });
}
});
}
async function handleSocketMessage(userId, sessionId, socket, rawMessage) {
const authContext = await authenticateTokenFromSession(userId, sessionId);
if (!authContext) {
send(socket, { type: 'error', message: 'Session expired.' });
socket.close();
return;
}
const parsed = parseClientMessage(rawMessage);
if (!parsed) {
send(socket, { type: 'error', message: 'Unsupported signaling message.' });
return;
}
let delivered = 0;
const recipientSockets = socketsByUserId.get(parsed.to);
if (recipientSockets) {
for (const [recipientSessionId, recipientSocket] of recipientSockets.entries()) {
const recipientContext = await authenticateTokenFromSession(parsed.to, recipientSessionId);
if (!recipientContext) {
recipientSocket.close();
continue;
}
send(recipientSocket, {
type: 'signal',
from: authContext.user.id,
signal: parsed.signal,
});
delivered += 1;
}
}
if (delivered === 0) {
send(socket, { type: 'error', message: 'Peer is offline or not authenticated.' });
}
}
function createAuthReply(user, sessionId) {
return {
token: app.jwt.sign({
sub: user.id,
username: user.username,
displayName: user.displayName,
sid: sessionId,
}),
user: toPublicUser(user),
messageEncryptionKey: user.messageEncryptionKey,
};
}
async function authenticateRequest(request, reply) {
const token = extractBearerToken(request.headers.authorization);
if (!token) {
reply.code(401).send({ message: 'Authentication required.' });
return null;
}
const authContext = await authenticateToken(token);
if (!authContext) {
reply.code(401).send({ message: 'Invalid or expired session.' });
return null;
}
return authContext;
}
async function authenticateToken(token) {
let decoded;
try {
decoded = app.jwt.verify(token);
}
catch {
return null;
}
return authenticateTokenFromSession(decoded.sub, decoded.sid, decoded);
}
async function authenticateTokenFromSession(userId, sessionId, decoded) {
const session = await getSession(sessionId);
if (!session || session.userId !== userId) {
return null;
}
const user = findUserById(userId);
if (!user) {
await destroySession(sessionId);
return null;
}
if (!user.isActive) {
await destroySession(sessionId);
return null;
}
await redis.expire(sessionKey(sessionId), sessionTtlSeconds);
return {
user,
session,
token: decoded ?? {
sub: user.id,
username: user.username,
displayName: user.displayName,
sid: sessionId,
},
};
}
function createUser(input) {
const createdAt = new Date().toISOString();
const user = {
id: crypto.randomUUID(),
username: input.username,
displayName: input.displayName,
passwordHash: hashPassword(input.password),
messageEncryptionKey: generateUserMessageEncryptionKey(),
isActive: input.isActive,
createdAt,
approvedAt: input.isActive ? createdAt : null,
};
createUserStatement.run(user.id, user.username, user.displayName, encryptJson({
passwordHash: user.passwordHash,
messageEncryptionKey: user.messageEncryptionKey,
}), user.isActive ? 1 : 0, user.approvedAt, user.createdAt);
return user;
}
function listPendingApprovalUsers() {
const rows = selectPendingUsersStatement.all();
return rows.map((row) => ({
id: row.id,
username: row.username,
displayName: row.display_name,
createdAt: row.created_at,
}));
}
function approveUser(userId) {
const approvedAt = new Date().toISOString();
const result = approveUserStatement.run(approvedAt, userId);
if (result.changes === 0) {
return null;
}
return findUserById(userId);
}
function persistAccessKey(userId, input) {
createAccessKeyStatement.run(crypto.randomUUID(), userId, input.credentialId, input.label, encryptJson({
credentialId: input.credentialId,
publicKey: input.publicKey,
counter: input.counter,
transports: input.transports,
deviceType: input.deviceType,
backedUp: input.backedUp,
aaguid: input.aaguid,
}), new Date().toISOString());
}
function updateStoredAccessKey(storedAccessKey, changes) {
const nextRegistration = {
...storedAccessKey.registration,
counter: changes.counter,
deviceType: changes.deviceType,
backedUp: changes.backedUp,
};
updateAccessKeyStatement.run(encryptJson(nextRegistration), storedAccessKey.row.credential_id);
}
function findUserByUsername(username) {
const row = selectUserByUsernameStatement.get(username);
return row ? hydrateUser(row) : null;
}
function findUserById(userId) {
const row = selectUserByIdStatement.get(userId);
return row ? hydrateUser(row) : null;
}
function hydrateUser(row) {
const credentials = decryptJson(row.encrypted_credentials);
const messageEncryptionKey = credentials.messageEncryptionKey ?? generateUserMessageEncryptionKey();
if (!credentials.messageEncryptionKey) {
updateUserCredentialsStatement.run(encryptJson({
passwordHash: credentials.passwordHash,
messageEncryptionKey,
}), row.id);
}
return {
id: row.id,
username: row.username,
displayName: row.display_name,
passwordHash: credentials.passwordHash,
messageEncryptionKey,
isActive: row.is_active === 1,
createdAt: row.created_at,
approvedAt: row.approved_at,
};
}
function listStoredAccessKeys(userId) {
const rows = selectAccessKeysByUserStatement.all(userId);
return rows.map((row) => decryptJson(row.encrypted_registration));
}
function findStoredAccessKeyByCredentialId(credentialId) {
const row = selectAccessKeyByCredentialIdStatement.get(credentialId);
if (!row) {
return null;
}
const user = findUserById(row.user_id);
if (!user) {
return null;
}
return {
row,
user,
registration: decryptJson(row.encrypted_registration),
};
}
function listAccessKeys(userId) {
const rows = selectAccessKeysByUserStatement.all(userId);
return rows.map((row) => {
const registration = decryptJson(row.encrypted_registration);
return {
id: row.id,
credentialId: row.credential_id,
label: row.label,
transports: registration.transports,
deviceType: registration.deviceType,
backedUp: registration.backedUp,
aaguid: registration.aaguid,
createdAt: row.created_at,
};
});
}
function loadOrCreateSecret(name, createValue) {
const row = selectSecretStatement.get(name);
if (row) {
return decryptText(row.encrypted_value);
}
const value = createValue();
insertSecretStatement.run(name, encryptText(value), new Date().toISOString());
return value;
}
async function createSession(userId) {
const session = {
sessionId: crypto.randomUUID(),
userId,
createdAt: new Date().toISOString(),
};
await redis.set(sessionKey(session.sessionId), JSON.stringify(session), 'EX', sessionTtlSeconds);
return session;
}
async function getSession(sessionId) {
const payload = await redis.get(sessionKey(sessionId));
if (!payload) {
return null;
}
return JSON.parse(payload);
}
async function destroySession(sessionId) {
await redis.del(sessionKey(sessionId));
}
function sessionKey(sessionId) {
return `privatechat:session:${sessionId}`;
}
function webAuthnRegistrationKey(sessionId) {
return `privatechat:webauthn-registration:${sessionId}`;
}
function webAuthnAuthenticationKey(attemptId) {
return `privatechat:webauthn-authentication:${attemptId}`;
}
async function getPendingRegistration(sessionId) {
const payload = await redis.get(webAuthnRegistrationKey(sessionId));
if (!payload) {
return null;
}
return JSON.parse(payload);
}
async function clearPendingRegistration(sessionId) {
await redis.del(webAuthnRegistrationKey(sessionId));
}
async function getPendingAuthentication(attemptId) {
const payload = await redis.get(webAuthnAuthenticationKey(attemptId));
if (!payload) {
return null;
}
return JSON.parse(payload);
}
async function clearPendingAuthentication(attemptId) {
await redis.del(webAuthnAuthenticationKey(attemptId));
}
function closeSocketSession(userId, sessionId) {
const socket = socketsByUserId.get(userId)?.get(sessionId);
if (socket && socket.readyState < 2) {
socket.close();
}
}
function detachSocket(userId, sessionId) {
const userSockets = socketsByUserId.get(userId);
if (!userSockets) {
return false;
}
userSockets.delete(sessionId);
if (userSockets.size === 0) {
socketsByUserId.delete(userId);
return true;
}
return false;
}
function listOnlinePeers(currentUserId) {
const peers = [];
for (const [userId, sockets] of socketsByUserId.entries()) {
if (userId === currentUserId || sockets.size === 0) {
continue;
}
const user = findUserById(userId);
if (user) {
peers.push(toPublicUser(user));
}
}
return peers.sort((left, right) => left.displayName.localeCompare(right.displayName));
}
function broadcastExcept(userId, payload) {
for (const [peerId, sockets] of socketsByUserId.entries()) {
if (peerId === userId) {
continue;
}
for (const socket of sockets.values()) {
send(socket, payload);
}
}
}
function send(socket, payload) {
if (socket.readyState === 1) {
socket.send(JSON.stringify(payload));
}
}
function parseClientMessage(rawMessage) {
let payload;
try {
payload = JSON.parse(rawMessage);
}
catch {
return null;
}
const parsed = signalMessageSchema.safeParse(payload);
if (!parsed.success) {
return null;
}
return {
type: 'signal',
to: parsed.data.to,
signal: normalizeSignal(parsed.data.signal),
};
}
function normalizeSignal(signal) {
if (signal.type === 'sdp') {
return {
type: 'sdp',
description: signal.description,
};
}
return {
type: 'ice-candidate',
candidate: signal.candidate,
};
}
function extractBearerToken(authorizationHeader) {
if (!authorizationHeader?.startsWith('Bearer ')) {
return null;
}
return authorizationHeader.slice('Bearer '.length).trim();
}
function toPublicUser(user) {
return {
id: user.id,
username: user.username,
displayName: user.displayName,
};
}
function isApprovalAdmin(user) {
return user.username === approvalAdminUsername;
}
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const derived = crypto.scryptSync(password, salt, 64).toString('hex');
return `${salt}:${derived}`;
}
function verifyPassword(password, storedHash) {
const [salt, stored] = storedHash.split(':');
if (!salt || !stored) {
return false;
}
const incoming = crypto.scryptSync(password, salt, 64);
const saved = Buffer.from(stored, 'hex');
return incoming.length === saved.length && crypto.timingSafeEqual(incoming, saved);
}
function generateUserMessageEncryptionKey() {
return crypto.randomBytes(32).toString('base64url');
}
function encryptJson(value) {
return encryptText(JSON.stringify(value));
}
function decryptJson(payload) {
return JSON.parse(decryptText(payload));
}
function encryptText(value) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
}
function decryptText(payload) {
const [ivHex, authTagHex, encryptedHex] = payload.split(':');
if (!ivHex || !authTagHex || !encryptedHex) {
throw new Error('Encrypted payload is malformed.');
}
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey, Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedHex, 'hex')),
decipher.final(),
]);
return decrypted.toString('utf8');
}
function loadOrCreateMasterKey(masterKeyFilePath) {
if (process.env.PRIVATECHAT_MASTER_KEY) {
return process.env.PRIVATECHAT_MASTER_KEY;
}
if (fs.existsSync(masterKeyFilePath)) {
return fs.readFileSync(masterKeyFilePath, 'utf8').trim();
}
const generatedKey = crypto.randomBytes(32).toString('hex');
fs.writeFileSync(masterKeyFilePath, generatedKey, { mode: 0o600 });
return generatedKey;
}
function ensureUserApprovalColumns() {
const rows = database.prepare('PRAGMA table_info(users)').all();
const columnNames = new Set(rows.map((row) => row.name));
if (!columnNames.has('is_active')) {
database.exec('ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1');
}
if (!columnNames.has('approved_at')) {
database.exec('ALTER TABLE users ADD COLUMN approved_at TEXT');
}
database.exec(`
UPDATE users
SET approved_at = COALESCE(approved_at, created_at)
WHERE is_active = 1
`);
}
function deriveEncryptionKey(masterKeyMaterial) {
return crypto.createHash('sha256').update(masterKeyMaterial).digest();
}
function resolveStoragePath(targetPath) {
return path.isAbsolute(targetPath) ? targetPath : path.resolve(process.cwd(), targetPath);
}
function resolveProjectPath(targetPath) {
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
}
function resolveWebAuthnOrigin(request) {
const originHeader = request.headers.origin;
if (typeof originHeader === 'string' && originHeader.length > 0) {
return originHeader;
}
return webAuthnOrigin;
}
function resolveWebAuthnUserVerification(value) {
switch (value?.toLowerCase()) {
case 'discouraged':
return 'discouraged';
case 'required':
return 'required';
default:
return 'preferred';
}
}
function bytesToBase64Url(value) {
return Buffer.from(value).toString('base64url');
}
function defaultAccessKeyLabel() {
return `Access key ${new Date().toISOString().replace('T', ' ').slice(0, 16)}`;
}