feat: AES-256-GCM encryption service + auth module (JWT, guards, seed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-12 17:07:14 -04:00
parent 5a6c376821
commit 3b9a0118b5
12 changed files with 303 additions and 0 deletions

22
backend/prisma/seed.ts Normal file
View File

@ -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());

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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 };
}
}

View File

@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(1)
password: string;
}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@ -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 };
}
}

View File

@ -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;
}
}
}

View File

@ -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');
}
}

View File

@ -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 {}

View File

@ -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');
});
});

View File

@ -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);
});
});