1027 lines
37 KiB
JavaScript
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)}`;
|
|
}
|