From 3b9a0118b55ff0ec99f1b51612c43f44a3d789ce Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 12 Mar 2026 17:07:14 -0400 Subject: [PATCH] feat: AES-256-GCM encryption service + auth module (JWT, guards, seed) Co-Authored-By: Claude Sonnet 4.6 --- backend/prisma/seed.ts | 22 ++++++++++ backend/src/auth/auth.controller.ts | 20 +++++++++ backend/src/auth/auth.module.ts | 21 ++++++++++ backend/src/auth/auth.service.ts | 32 ++++++++++++++ backend/src/auth/dto/login.dto.ts | 10 +++++ backend/src/auth/jwt-auth.guard.ts | 5 +++ backend/src/auth/jwt.strategy.ts | 18 ++++++++ backend/src/auth/ws-auth.guard.ts | 19 +++++++++ backend/src/vault/encryption.service.ts | 41 ++++++++++++++++++ backend/src/vault/vault.module.ts | 13 ++++++ backend/test/auth.service.spec.ts | 56 +++++++++++++++++++++++++ backend/test/encryption.service.spec.ts | 46 ++++++++++++++++++++ 12 files changed, 303 insertions(+) create mode 100644 backend/prisma/seed.ts create mode 100644 backend/src/auth/auth.controller.ts create mode 100644 backend/src/auth/auth.module.ts create mode 100644 backend/src/auth/auth.service.ts create mode 100644 backend/src/auth/dto/login.dto.ts create mode 100644 backend/src/auth/jwt-auth.guard.ts create mode 100644 backend/src/auth/jwt.strategy.ts create mode 100644 backend/src/auth/ws-auth.guard.ts create mode 100644 backend/src/vault/encryption.service.ts create mode 100644 backend/src/vault/vault.module.ts create mode 100644 backend/test/auth.service.spec.ts create mode 100644 backend/test/encryption.service.spec.ts diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..ae814b9 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,22 @@ +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function main() { + const hash = await bcrypt.hash('wraith', 10); + await prisma.user.upsert({ + where: { email: 'admin@wraith.local' }, + update: {}, + create: { + email: 'admin@wraith.local', + passwordHash: hash, + displayName: 'Admin', + }, + }); + console.log('Seed complete: admin@wraith.local / wraith'); +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..d9edf8c --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Post, Get, Body, Request, UseGuards } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { LoginDto } from './dto/login.dto'; + +@Controller('auth') +export class AuthController { + constructor(private auth: AuthService) {} + + @Post('login') + login(@Body() dto: LoginDto) { + return this.auth.login(dto.email, dto.password); + } + + @UseGuards(JwtAuthGuard) + @Get('profile') + getProfile(@Request() req: any) { + return this.auth.getProfile(req.user.sub); + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..740c729 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { JwtStrategy } from './jwt.strategy'; +import { WsAuthGuard } from './ws-auth.guard'; + +@Module({ + imports: [ + PassportModule, + JwtModule.register({ + secret: process.env.JWT_SECRET, + signOptions: { expiresIn: '7d' }, + }), + ], + providers: [AuthService, JwtStrategy, WsAuthGuard], + controllers: [AuthController], + exports: [WsAuthGuard, JwtModule], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..7f3d812 --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,32 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from '../prisma/prisma.service'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class AuthService { + constructor( + private prisma: PrismaService, + private jwt: JwtService, + ) {} + + async login(email: string, password: string) { + const user = await this.prisma.user.findUnique({ where: { email } }); + if (!user) throw new UnauthorizedException('Invalid credentials'); + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) throw new UnauthorizedException('Invalid credentials'); + + const payload = { sub: user.id, email: user.email }; + return { + access_token: this.jwt.sign(payload), + user: { id: user.id, email: user.email, displayName: user.displayName }, + }; + } + + async getProfile(userId: number) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new UnauthorizedException(); + return { id: user.id, email: user.email, displayName: user.displayName }; + } +} diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..77c0139 --- /dev/null +++ b/backend/src/auth/dto/login.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class LoginDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(1) + password: string; +} diff --git a/backend/src/auth/jwt-auth.guard.ts b/backend/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/backend/src/auth/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts new file mode 100644 index 0000000..7bd78ae --- /dev/null +++ b/backend/src/auth/jwt.strategy.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET, + }); + } + + validate(payload: { sub: number; email: string }) { + return { sub: payload.sub, email: payload.email }; + } +} diff --git a/backend/src/auth/ws-auth.guard.ts b/backend/src/auth/ws-auth.guard.ts new file mode 100644 index 0000000..ea10e14 --- /dev/null +++ b/backend/src/auth/ws-auth.guard.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { WsException } from '@nestjs/websockets'; + +@Injectable() +export class WsAuthGuard { + constructor(private jwt: JwtService) {} + + validateClient(client: any): { sub: number; email: string } | null { + try { + const url = new URL(client.url || client._url, 'http://localhost'); + const token = url.searchParams.get('token'); + if (!token) throw new WsException('No token'); + return this.jwt.verify(token); + } catch { + return null; + } + } +} diff --git a/backend/src/vault/encryption.service.ts b/backend/src/vault/encryption.service.ts new file mode 100644 index 0000000..c9c75df --- /dev/null +++ b/backend/src/vault/encryption.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +@Injectable() +export class EncryptionService { + private readonly algorithm = 'aes-256-gcm'; + private readonly key: Buffer; + + 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'); + } + + encrypt(plaintext: string): string { + const iv = randomBytes(16); + const cipher = createCipheriv(this.algorithm, this.key, 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')}`; + } + + 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'); + } +} diff --git a/backend/src/vault/vault.module.ts b/backend/src/vault/vault.module.ts new file mode 100644 index 0000000..656bd6b --- /dev/null +++ b/backend/src/vault/vault.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { EncryptionService } from './encryption.service'; +import { CredentialsService } from './credentials.service'; +import { CredentialsController } from './credentials.controller'; +import { SshKeysService } from './ssh-keys.service'; +import { SshKeysController } from './ssh-keys.controller'; + +@Module({ + providers: [EncryptionService, CredentialsService, SshKeysService], + controllers: [CredentialsController, SshKeysController], + exports: [EncryptionService, CredentialsService, SshKeysService], +}) +export class VaultModule {} diff --git a/backend/test/auth.service.spec.ts b/backend/test/auth.service.spec.ts new file mode 100644 index 0000000..be98f40 --- /dev/null +++ b/backend/test/auth.service.spec.ts @@ -0,0 +1,56 @@ +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from '../src/auth/auth.service'; +import * as bcrypt from 'bcrypt'; + +describe('AuthService', () => { + let service: AuthService; + const mockPrisma = { + user: { + findUnique: jest.fn(), + count: jest.fn(), + create: jest.fn(), + }, + }; + const mockJwt = { + sign: jest.fn().mockReturnValue('mock-jwt-token'), + }; + + beforeEach(() => { + service = new AuthService(mockPrisma as any, mockJwt as any); + jest.clearAllMocks(); + }); + + it('returns token for valid credentials', async () => { + const hash = await bcrypt.hash('password123', 10); + mockPrisma.user.findUnique.mockResolvedValue({ + id: 1, + email: 'admin@wraith.local', + passwordHash: hash, + displayName: 'Admin', + }); + + const result = await service.login('admin@wraith.local', 'password123'); + expect(result).toEqual({ + access_token: 'mock-jwt-token', + user: { id: 1, email: 'admin@wraith.local', displayName: 'Admin' }, + }); + }); + + it('throws on wrong password', async () => { + const hash = await bcrypt.hash('correct', 10); + mockPrisma.user.findUnique.mockResolvedValue({ + id: 1, + email: 'admin@wraith.local', + passwordHash: hash, + }); + + await expect(service.login('admin@wraith.local', 'wrong')) + .rejects.toThrow('Invalid credentials'); + }); + + it('throws on unknown user', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + await expect(service.login('nobody@wraith.local', 'pass')) + .rejects.toThrow('Invalid credentials'); + }); +}); diff --git a/backend/test/encryption.service.spec.ts b/backend/test/encryption.service.spec.ts new file mode 100644 index 0000000..6e2db86 --- /dev/null +++ b/backend/test/encryption.service.spec.ts @@ -0,0 +1,46 @@ +import { EncryptionService } from '../src/vault/encryption.service'; + +describe('EncryptionService', () => { + let service: EncryptionService; + + beforeEach(() => { + // 32-byte key as 64-char hex string + process.env.ENCRYPTION_KEY = 'a'.repeat(64); + service = new EncryptionService(); + }); + + it('encrypts and decrypts a string', () => { + const plaintext = 'my-secret-password'; + const encrypted = service.encrypt(plaintext); + expect(encrypted).not.toEqual(plaintext); + expect(encrypted.startsWith('v1:')).toBe(true); + expect(service.decrypt(encrypted)).toEqual(plaintext); + }); + + it('produces different ciphertext for same plaintext (random IV)', () => { + const plaintext = 'same-input'; + const a = service.encrypt(plaintext); + const b = service.encrypt(plaintext); + expect(a).not.toEqual(b); + expect(service.decrypt(a)).toEqual(plaintext); + expect(service.decrypt(b)).toEqual(plaintext); + }); + + it('throws on tampered ciphertext', () => { + const encrypted = service.encrypt('test'); + const parts = encrypted.split(':'); + parts[3] = 'ff' + parts[3].slice(2); // tamper ciphertext + expect(() => service.decrypt(parts.join(':'))).toThrow(); + }); + + it('handles empty string', () => { + const encrypted = service.encrypt(''); + expect(service.decrypt(encrypted)).toEqual(''); + }); + + it('handles unicode', () => { + const plaintext = 'p@$$w0rd-日本語-🔑'; + const encrypted = service.encrypt(plaintext); + expect(service.decrypt(encrypted)).toEqual(plaintext); + }); +});