feat: vault — encrypted credentials + SSH key management

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-12 17:08:53 -04:00
parent 41411b0cb8
commit 5762eb706e
8 changed files with 318 additions and 0 deletions

View File

@ -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);
}
}

View File

@ -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 };
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCredentialDto } from './create-credential.dto';
export class UpdateCredentialDto extends PartialType(CreateCredentialDto) {}

View File

@ -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)
}

View File

@ -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);
}
}

View File

@ -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';
}
}
}