diff --git a/backend/src/consts/crypto/aesGcmKeyByteLength.ts b/backend/src/consts/crypto/aesGcmKeyByteLength.ts new file mode 100644 index 0000000..96b9c15 --- /dev/null +++ b/backend/src/consts/crypto/aesGcmKeyByteLength.ts @@ -0,0 +1 @@ +export const AES_GCM_KEY_BYTE_LENGTH = 32; diff --git a/backend/src/main.ts b/backend/src/main.ts index 6223018..fa90dce 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,4 +1,4 @@ -import { ValidationPipe } from '@nestjs/common'; +import { RequestMethod, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; @@ -12,7 +12,9 @@ import { HttpMethod } from './types/HttpMethod'; async function bootstrap() { const app = await NestFactory.create(AppModule, { bodyParser: false }); - app.setGlobalPrefix('api'); + app.setGlobalPrefix('api', { + exclude: [{ path: 'i/:id/:secret', method: RequestMethod.GET }] + }); const configService = app.get(ConfigService); const altchaService = app.get(AltchaService); diff --git a/backend/src/modules/upload/UploadModule.ts b/backend/src/modules/upload/UploadModule.ts index 29c42bb..0ba3a09 100644 --- a/backend/src/modules/upload/UploadModule.ts +++ b/backend/src/modules/upload/UploadModule.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { StorageModule } from '../storage/StorageModule'; +import { HotlinkImageController } from './controllers/HotlinkImageController'; import { UploadController } from './controllers/UploadController'; import { Upload } from './entities/Upload'; import { UploadHotlink } from './entities/UploadHotlink'; @@ -9,7 +10,7 @@ import { UploadService } from './services/UploadService'; @Module({ imports: [TypeOrmModule.forFeature([Upload, UploadZk, UploadHotlink]), StorageModule], - controllers: [UploadController], + controllers: [UploadController, HotlinkImageController], providers: [UploadService] }) export class UploadModule {} diff --git a/backend/src/modules/upload/controllers/HotlinkImageController.ts b/backend/src/modules/upload/controllers/HotlinkImageController.ts new file mode 100644 index 0000000..f63d11a --- /dev/null +++ b/backend/src/modules/upload/controllers/HotlinkImageController.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Param, StreamableFile } from '@nestjs/common'; +import { HotlinkImageParamsDto } from '../dto/HotlinkImageParamsDto'; +import { UploadService } from '../services/UploadService'; + +@Controller('i') +export class HotlinkImageController { + constructor(private readonly uploadService: UploadService) {} + + @Get(':id/:secret') + async getImage(@Param() params: HotlinkImageParamsDto): Promise { + const { body, mimeType } = await this.uploadService.getHotlinkImage(params.id, params.secret); + + return new StreamableFile(body, { type: mimeType }); + } +} diff --git a/backend/src/modules/upload/dto/HotlinkImageParamsDto.ts b/backend/src/modules/upload/dto/HotlinkImageParamsDto.ts new file mode 100644 index 0000000..56e780b --- /dev/null +++ b/backend/src/modules/upload/dto/HotlinkImageParamsDto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsUUID } from 'class-validator'; +import { AES_GCM_KEY_BYTE_LENGTH } from '../../../consts/crypto/aesGcmKeyByteLength'; +import { IsBase64Url } from '../../../validation/decorators/isBase64Url'; + +export class HotlinkImageParamsDto { + @IsNotEmpty() + @IsUUID() + id: string; + + @IsNotEmpty() + @IsBase64Url({ byteLength: AES_GCM_KEY_BYTE_LENGTH }) + secret: string; +} diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index 0087ea6..e1e8fbc 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { createHash } from 'node:crypto'; +import { createCipheriv, createHash, randomBytes } from 'node:crypto'; +import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength'; import type { DataSource, EntityManager, Repository } from 'typeorm'; import type { Upload } from '../entities/Upload'; import { fileTypeFromBuffer } from 'file-type'; @@ -40,6 +41,12 @@ const wrapFieldBase64 = (length: number): string => Buffer.alloc(length, 0x22).t const hashDeleteToken = (token: string): string => createHash('sha256').update(token, 'utf8').digest('hex'); +const encryptHotlinkCiphertext = (plaintext: Buffer, key: Buffer, iv: Buffer): Buffer => { + const cipher = createCipheriv('aes-256-gcm', key, iv); + + return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]); +}; + describe('UploadService', () => { let service: UploadService; let dataSource: jest.Mocked>; @@ -283,6 +290,64 @@ describe('UploadService', () => { }); }); + describe('getHotlinkImage', () => { + const dek = randomBytes(32); + const secret = dek.toString('base64url'); + const iv = Buffer.alloc(AES_GCM_IV_BYTE_LENGTH, 0x11); + const plaintext = Buffer.from('hotlink-plaintext'); + const ciphertext = encryptHotlinkCiphertext(plaintext, dek, iv); + + it('returns decrypted body and mime type for valid secret', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv, + hotlink: { mimeType: 'image/png' } + }); + storageService.read.mockResolvedValue(ciphertext); + + await expect(service.getHotlinkImage(fixedId, secret)).resolves.toEqual({ + body: plaintext, + mimeType: 'image/png' + }); + + expect(uploadRepository.findOne).toHaveBeenCalledWith({ + where: { id: fixedId, mode: UploadMode.Hotlink }, + relations: ['hotlink'] + }); + }); + + it('throws NotFoundException when upload is missing or not hotlink', async () => { + uploadRepository.findOne.mockResolvedValue(null); + + await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); + expect(storageService.read).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when secret is wrong but well-formed', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv, + hotlink: { mimeType: 'image/png' } + }); + storageService.read.mockResolvedValue(ciphertext); + + const wrongSecret = Buffer.alloc(32, 0x99).toString('base64url'); + + await expect(service.getHotlinkImage(fixedId, wrongSecret)).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when ciphertext file is missing', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv, + hotlink: { mimeType: 'image/png' } + }); + storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); + }); + }); + describe('transaction rollback on storage failure', () => { it('propagates storage write errors from the transaction', async () => { storageService.write.mockRejectedValue(new Error('disk full')); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index d5500af..e50efaf 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -3,7 +3,9 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { fileTypeFromBuffer } from 'file-type'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { DataSource, Repository } from 'typeorm'; +import { decryptAesGcm } from '../../../utils/decryptAesGcm'; import { fromBase64 } from '../../../utils/fromBase64'; +import { fromBase64Url } from '../../../utils/fromBase64Url'; import { isErrorEnoent } from '../../../utils/isErrorEnoent'; import { toBase64 } from '../../../utils/toBase64'; import { StorageService } from '../../storage/services/StorageService'; @@ -13,6 +15,7 @@ import { Upload } from '../entities/Upload'; import { CreatedUploadResult } from '../types/CreatedUploadResult'; import { UploadMode } from '../types/UploadMode'; import { GetZkUploadCiphertextResult } from '../types/GetZkUploadCiphertextResult'; +import { HotlinkImageResult } from '../types/HotlinkImageResult'; @Injectable() export class UploadService { @@ -155,6 +158,44 @@ export class UploadService { return result; } + async getHotlinkImage(id: string, secret: string): Promise { + const upload = await this.uploadRepository.findOne({ + where: { id, mode: UploadMode.Hotlink }, + relations: ['hotlink'] + }); + + if (!upload?.hotlink) { + throw new NotFoundException(); + } + + let ciphertextBuffer: Buffer; + + try { + ciphertextBuffer = await this.storageService.read(upload.storagePath); + } catch (error: unknown) { + if (isErrorEnoent(error)) { + throw new NotFoundException(); + } + + throw error; + } + + let body: Buffer; + + try { + const dek = fromBase64Url(secret); + + body = decryptAesGcm(ciphertextBuffer, dek, upload.iv); + } catch { + throw new NotFoundException(); + } + + return { + body, + mimeType: upload.hotlink.mimeType + }; + } + private hashDeleteToken(token: string): string { return createHash('sha256').update(token, 'utf8').digest('hex'); } diff --git a/backend/src/modules/upload/types/HotlinkImageResult.ts b/backend/src/modules/upload/types/HotlinkImageResult.ts new file mode 100644 index 0000000..97b35ee --- /dev/null +++ b/backend/src/modules/upload/types/HotlinkImageResult.ts @@ -0,0 +1,4 @@ +export type HotlinkImageResult = { + body: Buffer; + mimeType: string; +}; diff --git a/backend/src/utils/decryptAesGcm.spec.ts b/backend/src/utils/decryptAesGcm.spec.ts new file mode 100644 index 0000000..d4542ba --- /dev/null +++ b/backend/src/utils/decryptAesGcm.spec.ts @@ -0,0 +1,107 @@ +import { createCipheriv, randomBytes } from 'node:crypto'; +import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '../consts/crypto/aesGcmAuthTagByteLength'; +import { AES_GCM_IV_BYTE_LENGTH } from '../consts/crypto/aesGcmIvByteLength'; +import { AES_GCM_KEY_BYTE_LENGTH } from '../consts/crypto/aesGcmKeyByteLength'; +import { decryptAesGcm } from './decryptAesGcm'; + +const encryptAesGcmLikeWebCrypto = (plaintext: Buffer, key: Buffer, iv: Buffer): Buffer => { + const cipher = createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return Buffer.concat([encrypted, authTag]); +}; + +describe('decryptAesGcm', () => { + const key = randomBytes(AES_GCM_KEY_BYTE_LENGTH); + const iv = randomBytes(AES_GCM_IV_BYTE_LENGTH); + + it('decrypts Web Crypto style ciphertext (ciphertext || auth tag)', () => { + const plaintext = Buffer.from('hotlink-image', 'utf8'); + const ciphertext = encryptAesGcmLikeWebCrypto(plaintext, key, iv); + + expect(decryptAesGcm(ciphertext, key, iv)).toEqual(plaintext); + }); + + it('round-trips empty plaintext', () => { + const plaintext = Buffer.alloc(0); + const ciphertext = encryptAesGcmLikeWebCrypto(plaintext, key, iv); + + expect(ciphertext).toHaveLength(AES_GCM_AUTH_TAG_BYTE_LENGTH); + expect(decryptAesGcm(ciphertext, key, iv)).toEqual(plaintext); + }); + + it.each([64, 512, 4096])('round-trips %i-byte binary payloads', size => { + const plaintext = randomBytes(size); + const ciphertext = encryptAesGcmLikeWebCrypto(plaintext, key, iv); + + expect(decryptAesGcm(ciphertext, key, iv)).toEqual(plaintext); + }); + + describe('input validation', () => { + const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('x'), key, iv); + + it.each([AES_GCM_IV_BYTE_LENGTH - 1, AES_GCM_IV_BYTE_LENGTH + 1])( + 'rejects IV length %i bytes', + ivLength => { + expect(() => decryptAesGcm(ciphertext, key, Buffer.alloc(ivLength))).toThrow( + `AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes` + ); + } + ); + + it.each([AES_GCM_KEY_BYTE_LENGTH - 1, AES_GCM_KEY_BYTE_LENGTH + 1])( + 'rejects key length %i bytes', + keyLength => { + expect(() => decryptAesGcm(ciphertext, Buffer.alloc(keyLength), iv)).toThrow( + `AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes` + ); + } + ); + + it('rejects ciphertext shorter than the auth tag', () => { + expect(() => decryptAesGcm(Buffer.alloc(AES_GCM_AUTH_TAG_BYTE_LENGTH - 1), key, iv)).toThrow( + 'AES-GCM ciphertext is too short' + ); + }); + }); + + describe('integrity and keying', () => { + it('throws when auth tag is invalid', () => { + const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('x'), key, iv); + + ciphertext[ciphertext.length - 1] ^= 0xff; + + expect(() => decryptAesGcm(ciphertext, key, iv)).toThrow(); + }); + + it('throws when encrypted bytes are tampered', () => { + const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('integrity-checked', 'utf8'), key, iv); + + ciphertext[0] ^= 0xff; + + expect(() => decryptAesGcm(ciphertext, key, iv)).toThrow(); + }); + + it('throws with the wrong key', () => { + const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('wrong-key', 'utf8'), key, iv); + const otherKey = randomBytes(AES_GCM_KEY_BYTE_LENGTH); + + expect(() => decryptAesGcm(ciphertext, otherKey, iv)).toThrow(); + }); + + it('throws with the wrong IV', () => { + const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('wrong-iv', 'utf8'), key, iv); + const otherIv = randomBytes(AES_GCM_IV_BYTE_LENGTH); + + expect(() => decryptAesGcm(ciphertext, key, otherIv)).toThrow(); + }); + + it('throws when ciphertext is truncated by one byte', () => { + const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('truncated', 'utf8'), key, iv); + const truncated = ciphertext.subarray(0, ciphertext.length - 1); + + expect(() => decryptAesGcm(truncated, key, iv)).toThrow(); + }); + }); +}); diff --git a/backend/src/utils/decryptAesGcm.ts b/backend/src/utils/decryptAesGcm.ts new file mode 100644 index 0000000..d59397b --- /dev/null +++ b/backend/src/utils/decryptAesGcm.ts @@ -0,0 +1,27 @@ +import { createDecipheriv } from 'node:crypto'; +import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '../consts/crypto/aesGcmAuthTagByteLength'; +import { AES_GCM_IV_BYTE_LENGTH } from '../consts/crypto/aesGcmIvByteLength'; +import { AES_GCM_KEY_BYTE_LENGTH } from '../consts/crypto/aesGcmKeyByteLength'; + +export const decryptAesGcm = (ciphertext: Buffer, key: Buffer, iv: Buffer): Buffer => { + if (iv.length !== AES_GCM_IV_BYTE_LENGTH) { + throw new Error(`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`); + } + + if (key.length !== AES_GCM_KEY_BYTE_LENGTH) { + throw new Error(`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`); + } + + if (ciphertext.length < AES_GCM_AUTH_TAG_BYTE_LENGTH) { + throw new Error('AES-GCM ciphertext is too short'); + } + + const authTag = ciphertext.subarray(ciphertext.length - AES_GCM_AUTH_TAG_BYTE_LENGTH); + const encrypted = ciphertext.subarray(0, ciphertext.length - AES_GCM_AUTH_TAG_BYTE_LENGTH); + + const decipher = createDecipheriv('aes-256-gcm', key, iv); + + decipher.setAuthTag(authTag); + + return Buffer.concat([decipher.update(encrypted), decipher.final()]); +}; diff --git a/backend/src/utils/fromBase64Url.spec.ts b/backend/src/utils/fromBase64Url.spec.ts new file mode 100644 index 0000000..9b70911 --- /dev/null +++ b/backend/src/utils/fromBase64Url.spec.ts @@ -0,0 +1,82 @@ +import { randomBytes } from 'node:crypto'; +import { AES_GCM_KEY_BYTE_LENGTH } from '../consts/crypto/aesGcmKeyByteLength'; +import { fromBase64Url } from './fromBase64Url'; + +const toBase64UrlLikeClient = (bytes: Buffer): string => { + let base64 = bytes.toString('base64').split('+').join('-').split('/').join('_'); + + while (base64.endsWith('=')) { + base64 = base64.slice(0, -1); + } + + return base64; +}; + +describe('fromBase64Url', () => { + it('decodes unpadded base64url utf8 payloads', () => { + expect(fromBase64Url('aGVsbG8')).toEqual(Buffer.from('hello', 'utf8')); + }); + + it('decodes empty string to an empty buffer', () => { + expect(fromBase64Url('')).toEqual(Buffer.alloc(0)); + }); + + it('decodes base64url with - and _ instead of + and /', () => { + const bytes = Buffer.from([0xfb, 0xff, 0xfe]); + const url = bytes.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + + expect(fromBase64Url(url)).toEqual(bytes); + }); + + it('restores padding when the input omits trailing equals', () => { + expect(fromBase64Url('AQIDBA')).toEqual(Buffer.from([0x01, 0x02, 0x03, 0x04])); + }); + + it('decodes when padding characters are already present', () => { + expect(fromBase64Url('YQ==')).toEqual(Buffer.from('a', 'utf8')); + expect(fromBase64Url('YWI=')).toEqual(Buffer.from('ab', 'utf8')); + }); + + it('matches Node base64url decoding for random 32-byte keys', () => { + const dek = randomBytes(AES_GCM_KEY_BYTE_LENGTH); + const secret = dek.toString('base64url'); + + expect(fromBase64Url(secret)).toEqual(dek); + expect(fromBase64Url(secret)).toEqual(Buffer.from(secret, 'base64url')); + }); + + describe('round-trip with client-style encoding', () => { + it.each([0, 1, 2, 3, 4, 12, 16, 32, 64])('decodes %i-byte payloads', length => { + const bytes = randomBytes(length); + const encoded = toBase64UrlLikeClient(bytes); + + expect(fromBase64Url(encoded)).toEqual(bytes); + }); + + it('decodes fixed-byte patterns used in crypto tests', () => { + const iv = Buffer.alloc(12, 0x11); + const dek = Buffer.alloc(32, 0x42); + + expect(fromBase64Url(toBase64UrlLikeClient(iv))).toEqual(iv); + expect(fromBase64Url(toBase64UrlLikeClient(dek))).toEqual(dek); + }); + }); + + describe('agrees with Buffer.from(..., "base64url")', () => { + const samples = [ + 'aGVsbG8', + toBase64UrlLikeClient(Buffer.from([0xff, 0xfe, 0xfd])), + randomBytes(48).toString('base64url') + ]; + + it.each(samples)('matches for encoded sample', encoded => { + expect(fromBase64Url(encoded)).toEqual(Buffer.from(encoded, 'base64url')); + }); + }); + + it('decodes standard base64 alphabet when plus and slash are passed through', () => { + const standard = Buffer.from('binary+/', 'utf8').toString('base64'); + + expect(fromBase64Url(standard)).toEqual(Buffer.from('binary+/', 'utf8')); + }); +}); diff --git a/backend/src/utils/fromBase64Url.ts b/backend/src/utils/fromBase64Url.ts new file mode 100644 index 0000000..c6ae3f1 --- /dev/null +++ b/backend/src/utils/fromBase64Url.ts @@ -0,0 +1,11 @@ +export const fromBase64Url = (base64Url: string): Buffer => { + let base64 = base64Url.split('-').join('+').split('_').join('/'); + + const padding = base64.length % 4; + + if (padding > 0) { + base64 += '='.repeat(4 - padding); + } + + return Buffer.from(base64, 'base64'); +}; diff --git a/backend/src/validation/decorators/isBase64Url.spec.ts b/backend/src/validation/decorators/isBase64Url.spec.ts new file mode 100644 index 0000000..17cbe4d --- /dev/null +++ b/backend/src/validation/decorators/isBase64Url.spec.ts @@ -0,0 +1,266 @@ +import 'reflect-metadata'; +import { randomBytes } from 'node:crypto'; +import { validateSync, type ValidationError } from 'class-validator'; +import { AES_GCM_KEY_BYTE_LENGTH } from '../../consts/crypto/aesGcmKeyByteLength'; +import { IsBase64Url } from './isBase64Url'; + +/** Matches frontend `bytesToBase64Url` (strip padding, url-safe alphabet). */ +const toBase64UrlLikeClient = (bytes: Buffer): string => { + let base64 = bytes.toString('base64').split('+').join('-').split('/').join('_'); + + while (base64.endsWith('=')) { + base64 = base64.slice(0, -1); + } + + return base64; +}; + +class UnboundedBase64UrlDto { + @IsBase64Url() + payload: string; +} + +class TwelveByteBase64UrlDto { + @IsBase64Url({ byteLength: 12 }) + iv: string; +} + +class ThirtyTwoByteBase64UrlDto { + @IsBase64Url({ byteLength: AES_GCM_KEY_BYTE_LENGTH }) + secret: string; +} + +class DualFieldBase64UrlDto { + @IsBase64Url({ byteLength: 12 }) + iv: string; + + @IsBase64Url() + ciphertext: string; +} + +const validateField = (DtoClass: new () => T, property: keyof T & string, value: unknown) => { + const dto = Object.assign(new DtoClass(), { [property]: value }); + + return validateSync(dto); +}; + +const constraintMessage = (errors: ValidationError[], property: string): string | undefined => { + const error = errors.find(e => e.property === property); + + return error?.constraints?.isBase64Url; +}; + +const accepts = (DtoClass: new () => T, property: keyof T & string, value: unknown) => { + expect(validateField(DtoClass, property, value)).toHaveLength(0); +}; + +const rejects = (DtoClass: new () => T, property: keyof T & string, value: unknown) => { + expect(validateField(DtoClass, property, value).length).toBeGreaterThan(0); +}; + +describe('IsBase64Url', () => { + describe('without byteLength', () => { + it('accepts unpadded base64url for short utf8 payloads', () => { + accepts(UnboundedBase64UrlDto, 'payload', toBase64UrlLikeClient(Buffer.from('hello', 'utf8'))); + }); + + it('accepts Node base64url encoding for binary payloads', () => { + accepts(UnboundedBase64UrlDto, 'payload', randomBytes(48).toString('base64url')); + }); + + it('accepts client-style encoding without padding for 32-byte keys', () => { + accepts(UnboundedBase64UrlDto, 'payload', toBase64UrlLikeClient(Buffer.alloc(32, 0x42))); + }); + + it('accepts hyphen and underscore in the alphabet portion', () => { + const raw = Buffer.from([0xfb, 0xff, 0xfe, 0xfd, 0xfb, 0xfa, 0xf9, 0xf8]); + + accepts(UnboundedBase64UrlDto, 'payload', toBase64UrlLikeClient(raw)); + }); + + it.each([null, undefined, 0, 1, false, true, {}, [], Symbol('x')])('rejects non-string value %p', value => { + rejects(UnboundedBase64UrlDto, 'payload', value); + }); + + it('rejects empty string', () => { + rejects(UnboundedBase64UrlDto, 'payload', ''); + }); + + it('rejects standard base64 with plus and slash', () => { + rejects(UnboundedBase64UrlDto, 'payload', Buffer.from('hello').toString('base64')); + }); + + it('rejects padding characters', () => { + rejects(UnboundedBase64UrlDto, 'payload', 'YQ=='); + rejects(UnboundedBase64UrlDto, 'payload', 'YWI='); + }); + + it.each(['@', '#', '$', '%', '.', ',', '!', '?', '\\', '"', "'", '+', '/'])( + 'rejects disallowed character %s', + char => { + rejects(UnboundedBase64UrlDto, 'payload', `YWJj${char}`); + } + ); + + it('rejects whitespace before valid base64url', () => { + rejects(UnboundedBase64UrlDto, 'payload', ` ${toBase64UrlLikeClient(Buffer.from('a'))}`); + }); + + it('rejects whitespace after valid base64url', () => { + rejects(UnboundedBase64UrlDto, 'payload', `${toBase64UrlLikeClient(Buffer.from('a'))} `); + }); + + it('rejects embedded newline', () => { + rejects(UnboundedBase64UrlDto, 'payload', 'YQ\n'); + }); + + it('rejects unicode outside base64url alphabet', () => { + rejects(UnboundedBase64UrlDto, 'payload', 'YQé'); + }); + + it('returns the generic error message', () => { + const errors = validateField(UnboundedBase64UrlDto, 'payload', 'not!!!valid'); + + expect(constraintMessage(errors, 'payload')).toBe('payload must be a valid base64url string'); + }); + }); + + describe('with byteLength', () => { + const twelveBytes = Buffer.alloc(12, 0x11); + const twelveBytesUrl = toBase64UrlLikeClient(twelveBytes); + + it('accepts base64url that decodes to exactly 12 bytes', () => { + accepts(TwelveByteBase64UrlDto, 'iv', twelveBytesUrl); + }); + + it('accepts 32-byte secrets when byteLength is 32', () => { + accepts(ThirtyTwoByteBase64UrlDto, 'secret', toBase64UrlLikeClient(Buffer.alloc(32, 0x42))); + }); + + it('accepts hotlink path secrets from Node base64url', () => { + const secret = randomBytes(AES_GCM_KEY_BYTE_LENGTH).toString('base64url'); + + accepts(ThirtyTwoByteBase64UrlDto, 'secret', secret); + }); + + it('rejects valid base64url that decodes to 11 bytes when 12 are required', () => { + rejects(TwelveByteBase64UrlDto, 'iv', toBase64UrlLikeClient(Buffer.alloc(11, 0x01))); + }); + + it('rejects valid base64url that decodes to 13 bytes when 12 are required', () => { + rejects(TwelveByteBase64UrlDto, 'iv', toBase64UrlLikeClient(Buffer.alloc(13, 0x01))); + }); + + it('rejects valid base64url that decodes to 1 byte when 32 are required', () => { + rejects(ThirtyTwoByteBase64UrlDto, 'secret', toBase64UrlLikeClient(Buffer.from('a'))); + }); + + it('rejects valid base64url that decodes to 31 bytes when 32 are required', () => { + rejects(ThirtyTwoByteBase64UrlDto, 'secret', toBase64UrlLikeClient(Buffer.alloc(31, 0x01))); + }); + + it('rejects valid base64url that decodes to 33 bytes when 32 are required', () => { + rejects(ThirtyTwoByteBase64UrlDto, 'secret', toBase64UrlLikeClient(Buffer.alloc(33, 0x01))); + }); + + it('rejects invalid base64url even when decoded length would have matched', () => { + rejects(TwelveByteBase64UrlDto, 'iv', `${twelveBytesUrl}!`); + }); + + it('rejects empty string when byteLength is set', () => { + rejects(TwelveByteBase64UrlDto, 'iv', ''); + }); + + it('rejects literal too-short path segments', () => { + rejects(ThirtyTwoByteBase64UrlDto, 'secret', 'too-short'); + }); + + it('returns byteLength-specific error message', () => { + const errors = validateField(TwelveByteBase64UrlDto, 'iv', toBase64UrlLikeClient(Buffer.from('short'))); + + expect(constraintMessage(errors, 'iv')).toBe('iv must be a base64url-encoded 12-byte value'); + }); + + it('returns byteLength-specific error message for 32-byte constraint', () => { + const errors = validateField(ThirtyTwoByteBase64UrlDto, 'secret', toBase64UrlLikeClient(Buffer.from('x'))); + + expect(constraintMessage(errors, 'secret')).toBe('secret must be a base64url-encoded 32-byte value'); + }); + }); + + describe('multiple decorated properties on one DTO', () => { + const iv = toBase64UrlLikeClient(Buffer.alloc(12, 0x99)); + const ciphertext = toBase64UrlLikeClient(Buffer.alloc(64, 0xcd)); + + it('accepts when both iv and ciphertext are valid', () => { + const dto = Object.assign(new DualFieldBase64UrlDto(), { iv, ciphertext }); + + expect(validateSync(dto)).toHaveLength(0); + }); + + it('rejects when only iv is invalid', () => { + const dto = Object.assign(new DualFieldBase64UrlDto(), { iv: 'bad', ciphertext }); + + const errors = validateSync(dto); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('iv'); + }); + + it('rejects when only ciphertext is invalid', () => { + const dto = Object.assign(new DualFieldBase64UrlDto(), { iv, ciphertext: '###' }); + + const errors = validateSync(dto); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('ciphertext'); + }); + + it('reports both properties when both are invalid', () => { + const dto = Object.assign(new DualFieldBase64UrlDto(), { iv: '', ciphertext: '' }); + + const errors = validateSync(dto); + + expect(errors.map(e => e.property).sort()).toEqual(['ciphertext', 'iv']); + }); + }); + + describe('with maxByteLength', () => { + class MaxByteLengthDto { + @IsBase64Url({ maxByteLength: 64 }) + payload: string; + } + + it('accepts base64url up to the decoded byte limit', () => { + accepts(MaxByteLengthDto, 'payload', toBase64UrlLikeClient(Buffer.alloc(64, 0x01))); + }); + + it('rejects base64url that decodes above the limit', () => { + rejects(MaxByteLengthDto, 'payload', toBase64UrlLikeClient(Buffer.alloc(65, 0x01))); + }); + + it('returns maxByteLength-specific error message', () => { + const errors = validateField(MaxByteLengthDto, 'payload', toBase64UrlLikeClient(Buffer.alloc(128, 0x01))); + + expect(constraintMessage(errors, 'payload')).toBe( + 'payload must be a base64url-encoded value of at most 64 bytes' + ); + }); + }); + + describe('default options object', () => { + class DefaultOptionsDto { + @IsBase64Url({}) + payload: string; + } + + it('behaves like unbounded base64url when options is an empty object', () => { + accepts(DefaultOptionsDto, 'payload', toBase64UrlLikeClient(Buffer.from('defaults'))); + }); + + it('rejects wrong byte length only when byteLength is explicitly set', () => { + rejects(TwelveByteBase64UrlDto, 'iv', toBase64UrlLikeClient(Buffer.from('x'))); + accepts(UnboundedBase64UrlDto, 'payload', toBase64UrlLikeClient(Buffer.from('x'))); + }); + }); +}); diff --git a/backend/src/validation/decorators/isBase64Url.ts b/backend/src/validation/decorators/isBase64Url.ts new file mode 100644 index 0000000..1bf328d --- /dev/null +++ b/backend/src/validation/decorators/isBase64Url.ts @@ -0,0 +1,57 @@ +import { + Validate, + ValidatorConstraint, + type ValidationArguments, + type ValidatorConstraintInterface +} from 'class-validator'; +import { fromBase64Url } from '../../utils/fromBase64Url'; +import type { IsBase64Options } from '../../types/validation/IsBase64Options'; + +const BASE64_URL_PATTERN = /^[A-Za-z0-9_-]+$/; + +@ValidatorConstraint({ name: 'isBase64Url' }) +class IsBase64UrlConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + if (typeof value !== 'string' || !value) { + return false; + } + + if (!BASE64_URL_PATTERN.test(value)) { + return false; + } + + const decoded = fromBase64Url(value); + + if (decoded.length === 0) { + return false; + } + + const [{ byteLength, maxByteLength }] = args.constraints as [IsBase64Options]; + + if (byteLength !== undefined) { + return decoded.length === byteLength; + } + + if (maxByteLength !== undefined) { + return decoded.length <= maxByteLength; + } + + return true; + } + + defaultMessage(args: ValidationArguments): string { + const [{ byteLength, maxByteLength }] = args.constraints as [IsBase64Options]; + + if (byteLength !== undefined) { + return `$property must be a base64url-encoded ${byteLength}-byte value`; + } + + if (maxByteLength !== undefined) { + return `$property must be a base64url-encoded value of at most ${maxByteLength} bytes`; + } + + return '$property must be a valid base64url string'; + } +} + +export const IsBase64Url = (options: IsBase64Options = {}) => Validate(IsBase64UrlConstraint, [options]);