diff --git a/backend/package-lock.json b/backend/package-lock.json index 9e04896..45a5788 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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" diff --git a/backend/package.json b/backend/package.json index b2e01e9..c7b3436 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/vault/credentials.controller.ts b/backend/src/vault/credentials.controller.ts index 7d67c21..fffdb6f 100644 --- a/backend/src/vault/credentials.controller.ts +++ b/backend/src/vault/credentials.controller.ts @@ -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 }; + } } diff --git a/backend/src/vault/credentials.service.ts b/backend/src/vault/credentials.service.ts index eef92b0..2368e47 100644 --- a/backend/src/vault/credentials.service.ts +++ b/backend/src/vault/credentials.service.ts @@ -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) { diff --git a/backend/src/vault/encryption.service.ts b/backend/src/vault/encryption.service.ts index c9c75df..e43add1 100644 --- a/backend/src/vault/encryption.service.ts +++ b/backend/src/vault/encryption.service.ts @@ -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 { + 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:::: + */ + async encrypt(plaintext: string): Promise { + 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 { + 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 { + if (!this.isV1(encrypted)) return null; + const plaintext = await this.decrypt(encrypted); + return this.encrypt(plaintext); } } diff --git a/backend/src/vault/ssh-keys.service.ts b/backend/src/vault/ssh-keys.service.ts index a38794c..3384b0c 100644 --- a/backend/src/vault/ssh-keys.service.ts +++ b/backend/src/vault/ssh-keys.service.ts @@ -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 }); diff --git a/docs/FUTURE-FEATURES.md b/docs/FUTURE-FEATURES.md new file mode 100644 index 0000000..3755a6b --- /dev/null +++ b/docs/FUTURE-FEATURES.md @@ -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