feat: vault — encrypted credentials + SSH key management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
41411b0cb8
commit
5762eb706e
36
backend/src/vault/credentials.controller.ts
Normal file
36
backend/src/vault/credentials.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
88
backend/src/vault/credentials.service.ts
Normal file
88
backend/src/vault/credentials.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
26
backend/src/vault/dto/create-credential.dto.ts
Normal file
26
backend/src/vault/dto/create-credential.dto.ts
Normal 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;
|
||||
}
|
||||
17
backend/src/vault/dto/create-ssh-key.dto.ts
Normal file
17
backend/src/vault/dto/create-ssh-key.dto.ts
Normal 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;
|
||||
}
|
||||
4
backend/src/vault/dto/update-credential.dto.ts
Normal file
4
backend/src/vault/dto/update-credential.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateCredentialDto } from './create-credential.dto';
|
||||
|
||||
export class UpdateCredentialDto extends PartialType(CreateCredentialDto) {}
|
||||
11
backend/src/vault/dto/update-ssh-key.dto.ts
Normal file
11
backend/src/vault/dto/update-ssh-key.dto.ts
Normal 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)
|
||||
}
|
||||
36
backend/src/vault/ssh-keys.controller.ts
Normal file
36
backend/src/vault/ssh-keys.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
100
backend/src/vault/ssh-keys.service.ts
Normal file
100
backend/src/vault/ssh-keys.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user