From 5762eb706ede2fb0ef8fe9161e963e6f55a6691d Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 12 Mar 2026 17:08:53 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20vault=20=E2=80=94=20encrypted=20credent?= =?UTF-8?q?ials=20+=20SSH=20key=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/src/vault/credentials.controller.ts | 36 +++++++ backend/src/vault/credentials.service.ts | 88 +++++++++++++++ .../src/vault/dto/create-credential.dto.ts | 26 +++++ backend/src/vault/dto/create-ssh-key.dto.ts | 17 +++ .../src/vault/dto/update-credential.dto.ts | 4 + backend/src/vault/dto/update-ssh-key.dto.ts | 11 ++ backend/src/vault/ssh-keys.controller.ts | 36 +++++++ backend/src/vault/ssh-keys.service.ts | 100 ++++++++++++++++++ 8 files changed, 318 insertions(+) create mode 100644 backend/src/vault/credentials.controller.ts create mode 100644 backend/src/vault/credentials.service.ts create mode 100644 backend/src/vault/dto/create-credential.dto.ts create mode 100644 backend/src/vault/dto/create-ssh-key.dto.ts create mode 100644 backend/src/vault/dto/update-credential.dto.ts create mode 100644 backend/src/vault/dto/update-ssh-key.dto.ts create mode 100644 backend/src/vault/ssh-keys.controller.ts create mode 100644 backend/src/vault/ssh-keys.service.ts diff --git a/backend/src/vault/credentials.controller.ts b/backend/src/vault/credentials.controller.ts new file mode 100644 index 0000000..1cd323d --- /dev/null +++ b/backend/src/vault/credentials.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CredentialsService } from './credentials.service'; +import { CreateCredentialDto } from './dto/create-credential.dto'; +import { UpdateCredentialDto } from './dto/update-credential.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('credentials') +export class CredentialsController { + constructor(private credentials: CredentialsService) {} + + @Get() + findAll() { + return this.credentials.findAll(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.credentials.findOne(id); + } + + @Post() + create(@Body() dto: CreateCredentialDto) { + return this.credentials.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCredentialDto) { + return this.credentials.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.credentials.remove(id); + } +} diff --git a/backend/src/vault/credentials.service.ts b/backend/src/vault/credentials.service.ts new file mode 100644 index 0000000..a1f2328 --- /dev/null +++ b/backend/src/vault/credentials.service.ts @@ -0,0 +1,88 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { EncryptionService } from './encryption.service'; +import { CreateCredentialDto } from './dto/create-credential.dto'; +import { UpdateCredentialDto } from './dto/update-credential.dto'; + +@Injectable() +export class CredentialsService { + constructor( + private prisma: PrismaService, + private encryption: EncryptionService, + ) {} + + findAll() { + return this.prisma.credential.findMany({ + include: { sshKey: { select: { id: true, name: true, keyType: true, fingerprint: true } } }, + orderBy: { name: 'asc' }, + }); + } + + async findOne(id: number) { + const cred = await this.prisma.credential.findUnique({ + where: { id }, + include: { sshKey: true, hosts: { select: { id: true, name: true } } }, + }); + if (!cred) throw new NotFoundException(`Credential ${id} not found`); + return cred; + } + + create(dto: CreateCredentialDto) { + const encryptedValue = dto.password ? this.encryption.encrypt(dto.password) : null; + return this.prisma.credential.create({ + data: { + name: dto.name, + username: dto.username, + domain: dto.domain, + type: dto.type, + encryptedValue, + sshKeyId: dto.sshKeyId, + }, + }); + } + + async update(id: number, dto: UpdateCredentialDto) { + await this.findOne(id); + const data: any = { ...dto }; + delete data.password; + if (dto.password) { + data.encryptedValue = this.encryption.encrypt(dto.password); + } + return this.prisma.credential.update({ where: { id }, data }); + } + + async remove(id: number) { + await this.findOne(id); + return this.prisma.credential.delete({ where: { id } }); + } + + /** Decrypt credential for use in SSH/RDP connections. Never expose over API. */ + async decryptForConnection(id: number): Promise<{ + username: string | null; + domain: string | null; + password: string | null; + sshKey: { privateKey: string; passphrase: string | null } | null; + }> { + const cred = await this.prisma.credential.findUnique({ + where: { id }, + include: { sshKey: true }, + }); + if (!cred) throw new NotFoundException(`Credential ${id} not found`); + + let password: string | null = null; + if (cred.encryptedValue) { + password = this.encryption.decrypt(cred.encryptedValue); + } + + let sshKey: { privateKey: string; passphrase: string | null } | null = null; + if (cred.sshKey) { + const privateKey = this.encryption.decrypt(cred.sshKey.encryptedPrivateKey); + const passphrase = cred.sshKey.passphraseEncrypted + ? this.encryption.decrypt(cred.sshKey.passphraseEncrypted) + : null; + sshKey = { privateKey, passphrase }; + } + + return { username: cred.username, domain: cred.domain, password, sshKey }; + } +} diff --git a/backend/src/vault/dto/create-credential.dto.ts b/backend/src/vault/dto/create-credential.dto.ts new file mode 100644 index 0000000..4b270b0 --- /dev/null +++ b/backend/src/vault/dto/create-credential.dto.ts @@ -0,0 +1,26 @@ +import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator'; +import { CredentialType } from '@prisma/client'; + +export class CreateCredentialDto { + @IsString() + name: string; + + @IsString() + @IsOptional() + username?: string; + + @IsString() + @IsOptional() + domain?: string; + + @IsEnum(CredentialType) + type: CredentialType; + + @IsString() + @IsOptional() + password?: string; // plaintext — encrypted before storage + + @IsInt() + @IsOptional() + sshKeyId?: number; +} diff --git a/backend/src/vault/dto/create-ssh-key.dto.ts b/backend/src/vault/dto/create-ssh-key.dto.ts new file mode 100644 index 0000000..d4fb9e4 --- /dev/null +++ b/backend/src/vault/dto/create-ssh-key.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class CreateSshKeyDto { + @IsString() + name: string; + + @IsString() + privateKey: string; // plaintext — encrypted before storage + + @IsString() + @IsOptional() + passphrase?: string; // plaintext — encrypted before storage + + @IsString() + @IsOptional() + publicKey?: string; +} diff --git a/backend/src/vault/dto/update-credential.dto.ts b/backend/src/vault/dto/update-credential.dto.ts new file mode 100644 index 0000000..0cfb002 --- /dev/null +++ b/backend/src/vault/dto/update-credential.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCredentialDto } from './create-credential.dto'; + +export class UpdateCredentialDto extends PartialType(CreateCredentialDto) {} diff --git a/backend/src/vault/dto/update-ssh-key.dto.ts b/backend/src/vault/dto/update-ssh-key.dto.ts new file mode 100644 index 0000000..97f6de7 --- /dev/null +++ b/backend/src/vault/dto/update-ssh-key.dto.ts @@ -0,0 +1,11 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class UpdateSshKeyDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + passphrase?: string; // new passphrase (re-encrypted) +} diff --git a/backend/src/vault/ssh-keys.controller.ts b/backend/src/vault/ssh-keys.controller.ts new file mode 100644 index 0000000..ae234e8 --- /dev/null +++ b/backend/src/vault/ssh-keys.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { SshKeysService } from './ssh-keys.service'; +import { CreateSshKeyDto } from './dto/create-ssh-key.dto'; +import { UpdateSshKeyDto } from './dto/update-ssh-key.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('ssh-keys') +export class SshKeysController { + constructor(private sshKeys: SshKeysService) {} + + @Get() + findAll() { + return this.sshKeys.findAll(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.sshKeys.findOne(id); + } + + @Post() + create(@Body() dto: CreateSshKeyDto) { + return this.sshKeys.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSshKeyDto) { + return this.sshKeys.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.sshKeys.remove(id); + } +} diff --git a/backend/src/vault/ssh-keys.service.ts b/backend/src/vault/ssh-keys.service.ts new file mode 100644 index 0000000..71e6c2a --- /dev/null +++ b/backend/src/vault/ssh-keys.service.ts @@ -0,0 +1,100 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { EncryptionService } from './encryption.service'; +import { CreateSshKeyDto } from './dto/create-ssh-key.dto'; +import { UpdateSshKeyDto } from './dto/update-ssh-key.dto'; +import { createHash } from 'crypto'; + +@Injectable() +export class SshKeysService { + constructor( + private prisma: PrismaService, + private encryption: EncryptionService, + ) {} + + findAll() { + return this.prisma.sshKey.findMany({ + select: { id: true, name: true, keyType: true, fingerprint: true, publicKey: true, createdAt: true }, + orderBy: { name: 'asc' }, + }); + } + + async findOne(id: number) { + const key = await this.prisma.sshKey.findUnique({ + where: { id }, + include: { credentials: { select: { id: true, name: true } } }, + }); + if (!key) throw new NotFoundException(`SSH key ${id} not found`); + // Never return encrypted private key over API + return { + id: key.id, + name: key.name, + keyType: key.keyType, + fingerprint: key.fingerprint, + publicKey: key.publicKey, + credentials: key.credentials, + createdAt: key.createdAt, + }; + } + + async create(dto: CreateSshKeyDto) { + // Detect key type from private key content + const keyType = this.detectKeyType(dto.privateKey); + + // Generate fingerprint from public key if provided, else from private key + const fingerprint = this.generateFingerprint(dto.publicKey || dto.privateKey); + + // Encrypt sensitive data + const encryptedPrivateKey = this.encryption.encrypt(dto.privateKey); + const passphraseEncrypted = dto.passphrase + ? this.encryption.encrypt(dto.passphrase) + : null; + + return this.prisma.sshKey.create({ + data: { + name: dto.name, + keyType, + fingerprint, + publicKey: dto.publicKey || null, + encryptedPrivateKey, + passphraseEncrypted, + }, + }); + } + + async update(id: number, dto: UpdateSshKeyDto) { + const key = await this.prisma.sshKey.findUnique({ where: { id } }); + if (!key) throw new NotFoundException(`SSH key ${id} not found`); + + const data: any = {}; + if (dto.name) data.name = dto.name; + if (dto.passphrase !== undefined) { + data.passphraseEncrypted = dto.passphrase + ? this.encryption.encrypt(dto.passphrase) + : null; + } + return this.prisma.sshKey.update({ where: { id }, data }); + } + + async remove(id: number) { + const key = await this.prisma.sshKey.findUnique({ where: { id } }); + if (!key) throw new NotFoundException(`SSH key ${id} not found`); + return this.prisma.sshKey.delete({ where: { id } }); + } + + private detectKeyType(privateKey: string): string { + if (privateKey.includes('RSA')) return 'rsa'; + if (privateKey.includes('EC')) return 'ecdsa'; + if (privateKey.includes('OPENSSH')) return 'ed25519'; // OpenSSH format, likely ed25519 + return 'unknown'; + } + + private generateFingerprint(keyContent: string): string { + try { + const hash = createHash('sha256').update(keyContent.trim()).digest('base64'); + return `SHA256:${hash}`; + } catch { + return 'unknown'; + } + } +}