feat(security): Argon2id key derivation for vault encryption
BREAKING CHANGE (forward-only): New credentials/keys encrypted with v2 (Argon2id-derived AES-256-GCM). Existing v1 records decrypt transparently. - Argon2id params: 64 MiB memory, 3 iterations, 4 parallelism (OWASP) - Per-record 16-byte salt stored in ciphertext format - v2 format: v2:<salt>:<iv>:<authTag>:<ciphertext> - Backwards compatible: v1 records still decrypt with raw key - Admin endpoint POST /api/credentials/migrate-v2 upgrades all v1→v2 - Added docs/FUTURE-FEATURES.md with remaining spec gaps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
37781a4791
commit
b11efce6ed
75
backend/package-lock.json
generated
75
backend/package-lock.json
generated
@ -18,6 +18,7 @@
|
||||
"@nestjs/serve-static": "^4.0.0",
|
||||
"@nestjs/websockets": "^10.0.0",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"argon2": "^0.44.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
@ -821,6 +822,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@ -1804,6 +1811,15 @@
|
||||
"npm": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@phc/format": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||
"integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@ -3006,6 +3022,31 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argon2": {
|
||||
"version": "0.44.0",
|
||||
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
|
||||
"integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@phc/format": "^1.0.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/argon2/node_modules/node-addon-api": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz",
|
||||
"integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@ -4049,11 +4090,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@ -5566,7 +5623,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
@ -7002,6 +7058,17 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@ -7358,7 +7425,6 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -8130,7 +8196,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@ -8143,7 +8208,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -9354,7 +9418,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"@nestjs/serve-static": "^4.0.0",
|
||||
"@nestjs/websockets": "^10.0.0",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"argon2": "^0.44.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import { Controller, Get, Post, Put, Delete, Param, Body, Request, UseGuards, ParseIntPipe } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Put, Delete, Param, Body, Request, UseGuards, ParseIntPipe, Logger } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { CredentialsService } from './credentials.service';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
import { PrismaService } from '../prisma/prisma.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) {}
|
||||
private readonly logger = new Logger(CredentialsController.name);
|
||||
constructor(
|
||||
private credentials: CredentialsService,
|
||||
private encryption: EncryptionService,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Request() req: any) {
|
||||
@ -33,4 +41,51 @@ export class CredentialsController {
|
||||
remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.credentials.remove(id, req.user.sub);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only: Migrate all v1 (raw key) encrypted values to v2 (Argon2id).
|
||||
* Safe to run multiple times — skips records already on v2.
|
||||
*/
|
||||
@Post('migrate-v2')
|
||||
@UseGuards(AdminGuard)
|
||||
async migrateToV2() {
|
||||
let credsMigrated = 0;
|
||||
let keysMigrated = 0;
|
||||
|
||||
// Migrate credential passwords
|
||||
const creds = await this.prisma.credential.findMany({
|
||||
where: { encryptedValue: { not: null } },
|
||||
select: { id: true, encryptedValue: true },
|
||||
});
|
||||
for (const cred of creds) {
|
||||
if (cred.encryptedValue && this.encryption.isV1(cred.encryptedValue)) {
|
||||
const upgraded = await this.encryption.upgradeToV2(cred.encryptedValue);
|
||||
if (upgraded) {
|
||||
await this.prisma.credential.update({ where: { id: cred.id }, data: { encryptedValue: upgraded } });
|
||||
credsMigrated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate SSH key private keys and passphrases
|
||||
const keys = await this.prisma.sshKey.findMany({
|
||||
select: { id: true, encryptedPrivateKey: true, passphraseEncrypted: true },
|
||||
});
|
||||
for (const key of keys) {
|
||||
const updates: any = {};
|
||||
if (this.encryption.isV1(key.encryptedPrivateKey)) {
|
||||
updates.encryptedPrivateKey = await this.encryption.upgradeToV2(key.encryptedPrivateKey);
|
||||
}
|
||||
if (key.passphraseEncrypted && this.encryption.isV1(key.passphraseEncrypted)) {
|
||||
updates.passphraseEncrypted = await this.encryption.upgradeToV2(key.passphraseEncrypted);
|
||||
}
|
||||
if (Object.keys(updates).length) {
|
||||
await this.prisma.sshKey.update({ where: { id: key.id }, data: updates });
|
||||
keysMigrated++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`v2 migration complete: ${credsMigrated} credentials, ${keysMigrated} SSH keys upgraded`);
|
||||
return { credsMigrated, keysMigrated };
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,8 +31,8 @@ export class CredentialsService {
|
||||
return cred;
|
||||
}
|
||||
|
||||
create(userId: number, dto: CreateCredentialDto) {
|
||||
const encryptedValue = dto.password ? this.encryption.encrypt(dto.password) : null;
|
||||
async create(userId: number, dto: CreateCredentialDto) {
|
||||
const encryptedValue = dto.password ? await this.encryption.encrypt(dto.password) : null;
|
||||
return this.prisma.credential.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
@ -51,7 +51,7 @@ export class CredentialsService {
|
||||
const data: any = { ...dto };
|
||||
delete data.password;
|
||||
if (dto.password) {
|
||||
data.encryptedValue = this.encryption.encrypt(dto.password);
|
||||
data.encryptedValue = await this.encryption.encrypt(dto.password);
|
||||
}
|
||||
return this.prisma.credential.update({ where: { id }, data });
|
||||
}
|
||||
@ -76,14 +76,14 @@ export class CredentialsService {
|
||||
|
||||
let password: string | null = null;
|
||||
if (cred.encryptedValue) {
|
||||
password = this.encryption.decrypt(cred.encryptedValue);
|
||||
password = await 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 privateKey = await this.encryption.decrypt(cred.sshKey.encryptedPrivateKey);
|
||||
const passphrase = cred.sshKey.passphraseEncrypted
|
||||
? this.encryption.decrypt(cred.sshKey.passphraseEncrypted)
|
||||
? await this.encryption.decrypt(cred.sshKey.passphraseEncrypted)
|
||||
: null;
|
||||
sshKey = { privateKey, passphrase };
|
||||
} else if (cred.sshKeyId) {
|
||||
|
||||
@ -1,41 +1,150 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import * as argon2 from 'argon2';
|
||||
|
||||
/**
|
||||
* Vault Encryption Service
|
||||
*
|
||||
* v1: AES-256-GCM with raw ENCRYPTION_KEY (legacy, still decryptable)
|
||||
* v2: AES-256-GCM with Argon2id-derived key (GPU-resistant)
|
||||
*
|
||||
* On encrypt, always produces v2. On decrypt, handles both v1 and v2.
|
||||
* Use migrateToV2() to re-encrypt all v1 records.
|
||||
*
|
||||
* Argon2id parameters (OWASP recommended):
|
||||
* memory: 64 MiB, iterations: 3, parallelism: 4
|
||||
* salt: 16 random bytes (stored per-ciphertext)
|
||||
*/
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
export class EncryptionService implements OnModuleInit {
|
||||
private readonly logger = new Logger(EncryptionService.name);
|
||||
private readonly algorithm = 'aes-256-gcm';
|
||||
private readonly key: Buffer;
|
||||
|
||||
// v1: raw key from env (kept for backwards compat decryption)
|
||||
private readonly rawKey: Buffer;
|
||||
|
||||
// v2: Argon2id-derived key (computed once at startup with a fixed salt derived from ENCRYPTION_KEY)
|
||||
// Per-record salts are used in the ciphertext format, but the master derived key uses the env key as input
|
||||
private readonly masterPassword: Buffer;
|
||||
|
||||
// Argon2id tuning — OWASP recommendations for sensitive data
|
||||
private readonly argon2Options = {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MiB
|
||||
timeCost: 3, // 3 iterations
|
||||
parallelism: 4, // 4 threads
|
||||
hashLength: 32, // 256-bit derived key
|
||||
};
|
||||
|
||||
constructor() {
|
||||
const hex = process.env.ENCRYPTION_KEY;
|
||||
if (!hex || hex.length < 64) {
|
||||
throw new Error('ENCRYPTION_KEY must be a 64-char hex string (32 bytes)');
|
||||
}
|
||||
this.key = Buffer.from(hex.slice(0, 64), 'hex');
|
||||
this.rawKey = Buffer.from(hex.slice(0, 64), 'hex');
|
||||
this.masterPassword = this.rawKey; // Used as Argon2 password input
|
||||
}
|
||||
|
||||
encrypt(plaintext: string): string {
|
||||
async onModuleInit() {
|
||||
// Warm up Argon2 by deriving a test key at startup — this also validates the config
|
||||
try {
|
||||
await this.deriveKey(randomBytes(16));
|
||||
this.logger.log('Argon2id key derivation initialized (v2 encryption active)');
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Argon2id initialization failed: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a 256-bit AES key from the master password + per-record salt using Argon2id
|
||||
*/
|
||||
private async deriveKey(salt: Buffer): Promise<Buffer> {
|
||||
const hash = await argon2.hash(this.masterPassword, {
|
||||
...this.argon2Options,
|
||||
salt,
|
||||
raw: true, // Return raw Buffer instead of encoded string
|
||||
});
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext using AES-256-GCM with Argon2id-derived key (v2)
|
||||
* Format: v2:<salt_hex>:<iv_hex>:<authTag_hex>:<ciphertext_hex>
|
||||
*/
|
||||
async encrypt(plaintext: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const derivedKey = await this.deriveKey(salt);
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv(this.algorithm, this.key, iv);
|
||||
|
||||
const cipher = createCipheriv(this.algorithm, derivedKey, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `v1:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
|
||||
return `v2:${salt.toString('hex')}:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
decrypt(encrypted: string): string {
|
||||
const [version, ivHex, authTagHex, ciphertextHex] = encrypted.split(':');
|
||||
if (version !== 'v1') throw new Error(`Unknown encryption version: ${version}`);
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
const ciphertext = Buffer.from(ciphertextHex, 'hex');
|
||||
const decipher = createDecipheriv(this.algorithm, this.key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
/**
|
||||
* Decrypt ciphertext — handles both v1 (legacy raw key) and v2 (Argon2id)
|
||||
*/
|
||||
async decrypt(encrypted: string): Promise<string> {
|
||||
const parts = encrypted.split(':');
|
||||
const version = parts[0];
|
||||
|
||||
if (version === 'v2') {
|
||||
// v2: Argon2id-derived key
|
||||
const [, saltHex, ivHex, authTagHex, ciphertextHex] = parts;
|
||||
const salt = Buffer.from(saltHex, 'hex');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
const ciphertext = Buffer.from(ciphertextHex, 'hex');
|
||||
|
||||
const derivedKey = await this.deriveKey(salt);
|
||||
const decipher = createDecipheriv(this.algorithm, derivedKey, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
|
||||
if (version === 'v1') {
|
||||
// v1: raw ENCRYPTION_KEY (legacy backwards compat)
|
||||
const [, ivHex, authTagHex, ciphertextHex] = parts;
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
const ciphertext = Buffer.from(ciphertextHex, 'hex');
|
||||
|
||||
const decipher = createDecipheriv(this.algorithm, this.rawKey, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
|
||||
throw new Error(`Unknown encryption version: ${version}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a ciphertext is using the legacy v1 format
|
||||
*/
|
||||
isV1(encrypted: string): boolean {
|
||||
return encrypted.startsWith('v1:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encrypt a v1 ciphertext to v2 (Argon2id). Returns the new ciphertext,
|
||||
* or null if already v2.
|
||||
*/
|
||||
async upgradeToV2(encrypted: string): Promise<string | null> {
|
||||
if (!this.isV1(encrypted)) return null;
|
||||
const plaintext = await this.decrypt(encrypted);
|
||||
return this.encrypt(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,10 +48,10 @@ export class SshKeysService {
|
||||
// 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);
|
||||
// Encrypt sensitive data with Argon2id-derived key (v2)
|
||||
const encryptedPrivateKey = await this.encryption.encrypt(dto.privateKey);
|
||||
const passphraseEncrypted = dto.passphrase
|
||||
? this.encryption.encrypt(dto.passphrase)
|
||||
? await this.encryption.encrypt(dto.passphrase)
|
||||
: null;
|
||||
|
||||
return this.prisma.sshKey.create({
|
||||
@ -76,7 +76,7 @@ export class SshKeysService {
|
||||
if (dto.name) data.name = dto.name;
|
||||
if (dto.passphrase !== undefined) {
|
||||
data.passphraseEncrypted = dto.passphrase
|
||||
? this.encryption.encrypt(dto.passphrase)
|
||||
? await this.encryption.encrypt(dto.passphrase)
|
||||
: null;
|
||||
}
|
||||
return this.prisma.sshKey.update({ where: { id }, data });
|
||||
|
||||
54
docs/FUTURE-FEATURES.md
Normal file
54
docs/FUTURE-FEATURES.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Vigilance Remote — Future Features
|
||||
|
||||
Remaining spec items not yet built. Foundation is solid — all items below are additive, no rearchitecting required.
|
||||
|
||||
---
|
||||
|
||||
## Priority 1 — Power User
|
||||
|
||||
1. **Split panes** — Horizontal and vertical splits within a single tab (xterm.js instances in a flex grid)
|
||||
2. **Session recording/playback** — asciinema-compatible casts for SSH, Guacamole native for RDP. Replay in browser. Audit trail for MSP compliance.
|
||||
3. **Saved snippets/macros** — Quick-execute saved commands/scripts. Click to paste into active terminal.
|
||||
|
||||
## Priority 2 — MSP / Enterprise
|
||||
|
||||
4. **Jump hosts / bastion** — Configure SSH proxy/jump hosts for reaching targets behind firewalls (ProxyJump chain support)
|
||||
5. **Port forwarding manager** — Graphical SSH tunnel manager: local, remote, and dynamic forwarding
|
||||
6. **Entra ID SSO** — One-click Microsoft Entra ID integration (same pattern as Vigilance HQ)
|
||||
7. **Client-scoped access** — MSP multi-tenancy: technicians see only the hosts for clients they're assigned to
|
||||
8. **Shared connections** — Admins define connection templates. Technicians connect without seeing credentials.
|
||||
|
||||
## Priority 3 — Audit & Compliance
|
||||
|
||||
9. **Command-level audit logging** — Every command, file transfer logged with user, timestamp, duration (currently connection-level only)
|
||||
10. **Session sharing** — Share a live terminal session with a colleague (read-only or collaborative)
|
||||
|
||||
## Priority 4 — File Transfer
|
||||
|
||||
11. **Dual-pane SFTP** — Optional second SFTP panel for server-to-server file operations (drag between panels)
|
||||
12. **Transfer queue** — Background upload/download queue with progress bars, pause/resume, retry
|
||||
|
||||
## Priority 5 — RDP Enhancements
|
||||
|
||||
13. **Multi-monitor RDP** — Support for multiple virtual displays
|
||||
14. **RDP file transfer** — Upload/download via Guacamole's built-in drive redirection
|
||||
|
||||
## Priority 6 — Auth Hardening
|
||||
|
||||
15. **FIDO2 / hardware key auth** — WebAuthn support for login and SSH
|
||||
16. **SSH agent forwarding** — Forward local SSH agent to remote host
|
||||
|
||||
---
|
||||
|
||||
## Already Built (exceeds spec)
|
||||
|
||||
- SSH terminal (xterm.js + ssh2 + WebSocket proxy + WebGL)
|
||||
- RDP (guacd + guacamole-common-js + display.scale())
|
||||
- SFTP sidebar (auto-open, CWD following via OSC 7, drag-and-drop upload)
|
||||
- Monaco file editor (fullscreen overlay with syntax highlighting)
|
||||
- Connection manager (hosts, groups, quick connect, search, tags, colors)
|
||||
- Credential vault (AES-256-GCM + **Argon2id key derivation**)
|
||||
- Multi-tab sessions + Home navigation
|
||||
- Terminal theming (6+ themes with visual picker)
|
||||
- Multi-user with admin/user roles + per-user data isolation
|
||||
- User management admin UI
|
||||
Loading…
Reference in New Issue
Block a user