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