connection profiles and ssh key encryption
This commit is contained in:
@@ -6,8 +6,10 @@ import prismaPlugin from './plugins/prisma';
|
||||
import { authRoutes } from './routes/auth';
|
||||
import { folderRoutes } from './routes/folders';
|
||||
import { connectionRoutes } from './routes/connections';
|
||||
import { profileRoutes } from './routes/profiles';
|
||||
import { sshWebsocket } from './websocket/ssh';
|
||||
import { rdpWebsocket } from './websocket/rdp';
|
||||
import { encrypt } from './utils/encryption';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -41,6 +43,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
await fastify.register(authRoutes, { prefix: '/api/auth' });
|
||||
await fastify.register(folderRoutes, { prefix: '/api/folders' });
|
||||
await fastify.register(connectionRoutes, { prefix: '/api/connections' });
|
||||
await fastify.register(profileRoutes, { prefix: '/api/profiles' });
|
||||
|
||||
// WebSocket routes
|
||||
await fastify.register(sshWebsocket);
|
||||
@@ -49,8 +52,29 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
return fastify;
|
||||
}
|
||||
|
||||
/** One-time migration: encrypt any plaintext private keys left in the DB.
|
||||
* Encrypted values contain a ":" (iv:ciphertext), plaintext keys don't. */
|
||||
async function migratePrivateKeys(app: FastifyInstance) {
|
||||
for (const table of ['connection', 'profile'] as const) {
|
||||
const rows = await (app.prisma[table] as any).findMany({
|
||||
where: { privateKey: { not: null } },
|
||||
select: { id: true, privateKey: true },
|
||||
});
|
||||
for (const row of rows) {
|
||||
if (row.privateKey && !row.privateKey.includes(':')) {
|
||||
await (app.prisma[table] as any).update({
|
||||
where: { id: row.id },
|
||||
data: { privateKey: encrypt(row.privateKey) },
|
||||
});
|
||||
app.log.info(`[migrate] Encrypted plaintext privateKey in ${table} ${row.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const app = await buildApp();
|
||||
await migratePrivateKeys(app);
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
await app.listen({ port, host: '0.0.0.0' });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { encrypt } from '../utils/encryption';
|
||||
import { encrypt, decrypt } from '../utils/encryption';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
@@ -19,6 +19,7 @@ interface ConnectionBody {
|
||||
notes?: string;
|
||||
clipboardEnabled?: boolean;
|
||||
folderId?: string | null;
|
||||
profileId?: string | null;
|
||||
}
|
||||
|
||||
export async function connectionRoutes(fastify: FastifyInstance) {
|
||||
@@ -42,6 +43,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
||||
notes: true,
|
||||
clipboardEnabled: true,
|
||||
folderId: true,
|
||||
profileId: true,
|
||||
createdAt: true,
|
||||
// Do NOT expose encryptedPassword or privateKey in list
|
||||
},
|
||||
@@ -66,25 +68,31 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
||||
notes: true,
|
||||
clipboardEnabled: true,
|
||||
folderId: true,
|
||||
profileId: true,
|
||||
privateKey: true,
|
||||
createdAt: true,
|
||||
// still omit encryptedPassword
|
||||
},
|
||||
});
|
||||
if (!conn) return reply.status(404).send({ error: 'Connection not found' });
|
||||
return reply.send(conn);
|
||||
return reply.send({
|
||||
...conn,
|
||||
privateKey: conn.privateKey ? decrypt(conn.privateKey) : null,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/connections
|
||||
fastify.post<{ Body: ConnectionBody }>('/', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { password, ...rest } = request.body;
|
||||
const { password, privateKey, ...rest } = request.body;
|
||||
|
||||
const connection = await fastify.prisma.connection.create({
|
||||
data: {
|
||||
...rest,
|
||||
folderId: rest.folderId || null,
|
||||
profileId: rest.profileId || null,
|
||||
encryptedPassword: password ? encrypt(password) : null,
|
||||
privateKey: privateKey ? encrypt(privateKey) : null,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
@@ -99,6 +107,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
||||
notes: true,
|
||||
clipboardEnabled: true,
|
||||
folderId: true,
|
||||
profileId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
@@ -111,7 +120,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
||||
async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { id } = request.params;
|
||||
const { password, folderId, ...rest } = request.body;
|
||||
const { password, privateKey, folderId, profileId, ...rest } = request.body;
|
||||
|
||||
const existing = await fastify.prisma.connection.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
@@ -123,7 +132,9 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
||||
data: {
|
||||
...rest,
|
||||
...(folderId !== undefined && { folderId: folderId ?? null }),
|
||||
...(profileId !== undefined && { profileId: profileId ?? null }),
|
||||
...(password !== undefined && { encryptedPassword: password ? encrypt(password) : null }),
|
||||
...(privateKey !== undefined && { privateKey: privateKey ? encrypt(privateKey) : null }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -137,6 +148,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
||||
notes: true,
|
||||
clipboardEnabled: true,
|
||||
folderId: true,
|
||||
profileId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
151
backend/src/routes/profiles.ts
Normal file
151
backend/src/routes/profiles.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { encrypt, decrypt } from '../utils/encryption';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ProfileBody {
|
||||
name: string;
|
||||
protocol: 'ssh' | 'rdp';
|
||||
username?: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
domain?: string;
|
||||
clipboardEnabled?: boolean;
|
||||
}
|
||||
|
||||
export async function profileRoutes(fastify: FastifyInstance) {
|
||||
fastify.addHook('preHandler', fastify.authenticate);
|
||||
|
||||
// GET /api/profiles
|
||||
fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const profiles = await fastify.prisma.profile.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
protocol: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
clipboardEnabled: true,
|
||||
privateKey: true,
|
||||
encryptedPassword: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
// Return hasPassword/hasPrivateKey flags instead of raw values
|
||||
const result = profiles.map(({ encryptedPassword, privateKey, ...rest }) => ({
|
||||
...rest,
|
||||
hasPassword: !!encryptedPassword,
|
||||
hasPrivateKey: !!privateKey,
|
||||
}));
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
// GET /api/profiles/:id
|
||||
fastify.get<{ Params: { id: string } }>('/:id', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const profile = await fastify.prisma.profile.findFirst({
|
||||
where: { id: request.params.id, userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
protocol: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
clipboardEnabled: true,
|
||||
privateKey: true,
|
||||
encryptedPassword: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
if (!profile) return reply.status(404).send({ error: 'Profile not found' });
|
||||
const { encryptedPassword, privateKey, ...rest } = profile;
|
||||
return reply.send({
|
||||
...rest,
|
||||
privateKey: privateKey ? decrypt(privateKey) : null,
|
||||
hasPassword: !!encryptedPassword,
|
||||
hasPrivateKey: !!privateKey,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/profiles
|
||||
fastify.post<{ Body: ProfileBody }>('/', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { password, privateKey, ...rest } = request.body;
|
||||
|
||||
const profile = await fastify.prisma.profile.create({
|
||||
data: {
|
||||
...rest,
|
||||
encryptedPassword: password ? encrypt(password) : null,
|
||||
privateKey: privateKey ? encrypt(privateKey) : null,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
protocol: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
clipboardEnabled: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return reply.status(201).send(profile);
|
||||
});
|
||||
|
||||
// PATCH /api/profiles/:id
|
||||
fastify.patch<{ Params: { id: string }; Body: Partial<ProfileBody> }>(
|
||||
'/:id',
|
||||
async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { id } = request.params;
|
||||
const { password, privateKey, ...rest } = request.body;
|
||||
|
||||
const existing = await fastify.prisma.profile.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
if (!existing) return reply.status(404).send({ error: 'Profile not found' });
|
||||
|
||||
const updated = await fastify.prisma.profile.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(password !== undefined && {
|
||||
encryptedPassword: password ? encrypt(password) : null,
|
||||
}),
|
||||
...(privateKey !== undefined && {
|
||||
privateKey: privateKey ? encrypt(privateKey) : null,
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
clipboardEnabled: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/profiles/:id
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { id } = request.params;
|
||||
|
||||
const existing = await fastify.prisma.profile.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
if (!existing) return reply.status(404).send({ error: 'Profile not found' });
|
||||
|
||||
await fastify.prisma.profile.delete({ where: { id } });
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
47
backend/src/utils/resolveCredentials.ts
Normal file
47
backend/src/utils/resolveCredentials.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { decrypt } from './encryption';
|
||||
|
||||
interface ProfileData {
|
||||
username: string | null;
|
||||
encryptedPassword: string | null;
|
||||
privateKey: string | null;
|
||||
domain: string | null;
|
||||
clipboardEnabled: boolean | null;
|
||||
}
|
||||
|
||||
interface ConnectionWithProfile {
|
||||
username: string;
|
||||
encryptedPassword: string | null;
|
||||
privateKey: string | null;
|
||||
domain: string | null;
|
||||
clipboardEnabled: boolean;
|
||||
profile: ProfileData | null;
|
||||
}
|
||||
|
||||
export interface ResolvedCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
privateKey: string | null;
|
||||
domain: string;
|
||||
clipboardEnabled: boolean;
|
||||
}
|
||||
|
||||
export function resolveCredentials(
|
||||
connection: ConnectionWithProfile
|
||||
): ResolvedCredentials {
|
||||
const profile = connection.profile;
|
||||
return {
|
||||
username: connection.username || profile?.username || '',
|
||||
password: connection.encryptedPassword
|
||||
? decrypt(connection.encryptedPassword)
|
||||
: profile?.encryptedPassword
|
||||
? decrypt(profile.encryptedPassword)
|
||||
: '',
|
||||
privateKey: connection.privateKey
|
||||
? decrypt(connection.privateKey)
|
||||
: profile?.privateKey
|
||||
? decrypt(profile.privateKey)
|
||||
: null,
|
||||
domain: connection.domain || profile?.domain || '',
|
||||
clipboardEnabled: connection.clipboardEnabled ?? profile?.clipboardEnabled ?? true,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { SocketStream } from '@fastify/websocket';
|
||||
import { WebSocket } from 'ws';
|
||||
import { decrypt } from '../utils/encryption';
|
||||
import { resolveCredentials } from '../utils/resolveCredentials';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
@@ -36,6 +36,7 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
||||
const { connectionId } = request.params as { connectionId: string };
|
||||
const conn = await fastify.prisma.connection.findFirst({
|
||||
where: { id: connectionId, userId },
|
||||
include: { profile: true },
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
@@ -44,10 +45,10 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
||||
}
|
||||
|
||||
const rdpdUrl = process.env.RDPD_URL || 'ws://localhost:7777';
|
||||
const decryptedPassword = conn.encryptedPassword ? decrypt(conn.encryptedPassword) : '';
|
||||
const creds = resolveCredentials(conn);
|
||||
|
||||
fastify.log.info(
|
||||
{ host: conn.host, port: conn.port, user: conn.username, rdpdUrl },
|
||||
{ host: conn.host, port: conn.port, user: creds.username, rdpdUrl },
|
||||
'RDP: connecting to rdpd'
|
||||
);
|
||||
|
||||
@@ -71,12 +72,12 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
||||
type: 'connect',
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
username: conn.username,
|
||||
password: decryptedPassword,
|
||||
domain: conn.domain || '',
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
domain: creds.domain,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
clipboard: conn.clipboardEnabled !== false,
|
||||
clipboard: creds.clipboardEnabled,
|
||||
});
|
||||
|
||||
rdpd.send(connectMsg);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { SocketStream } from '@fastify/websocket';
|
||||
import { WebSocket } from 'ws';
|
||||
import { Client as SshClient, ConnectConfig } from 'ssh2';
|
||||
import { decrypt } from '../utils/encryption';
|
||||
import { resolveCredentials } from '../utils/resolveCredentials';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
@@ -39,6 +39,7 @@ export async function sshWebsocket(fastify: FastifyInstance) {
|
||||
const { connectionId } = request.params as { connectionId: string };
|
||||
const conn = await fastify.prisma.connection.findFirst({
|
||||
where: { id: connectionId, userId },
|
||||
include: { profile: true },
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
@@ -47,17 +48,18 @@ export async function sshWebsocket(fastify: FastifyInstance) {
|
||||
}
|
||||
|
||||
// --- Build SSH config ---
|
||||
const creds = resolveCredentials(conn);
|
||||
const sshConfig: ConnectConfig = {
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
username: conn.username,
|
||||
username: creds.username,
|
||||
readyTimeout: 10_000,
|
||||
};
|
||||
|
||||
if (conn.privateKey) {
|
||||
sshConfig.privateKey = conn.privateKey;
|
||||
} else if (conn.encryptedPassword) {
|
||||
sshConfig.password = decrypt(conn.encryptedPassword);
|
||||
if (creds.privateKey) {
|
||||
sshConfig.privateKey = creds.privateKey;
|
||||
} else if (creds.password) {
|
||||
sshConfig.password = creds.password;
|
||||
}
|
||||
|
||||
// --- Open SSH session ---
|
||||
|
||||
Reference in New Issue
Block a user