File encryption support

This commit is contained in:
Wayne
2025-10-04 00:45:33 +02:00
parent f4dce6f1e9
commit 826fd6f965
9 changed files with 79 additions and 11 deletions

View File

@@ -55,6 +55,13 @@ STORAGE_S3_REGION=
# Set to 'true' for MinIO and other non-AWS S3 services
STORAGE_S3_FORCE_PATH_STYLE=false
# --- Storage Encryption ---
# IMPORTANT: Generate a secure, random 32-byte hex string for this key.
# You can use `openssl rand -hex 32` to generate a key.
# This key is used for AES-256 encryption of files at rest.
# This is an optional variable, if not set, files will not be encrypted.
STORAGE_ENCRYPTION_KEY=
# --- Security & Authentication ---
# Rate Limiting
@@ -79,5 +86,3 @@ ENCRYPTION_KEY=
# Apache Tika Integration
# ONLY active if TIKA_URL is set
TIKA_URL=http://tika:9998

View File

@@ -62,6 +62,10 @@ Now, open the `.env` file in a text editor and customize the settings.
```bash
openssl rand -hex 32
```
- `STORAGE_ENCRYPTION_KEY`: **(Optional but Recommended)** A 32-byte hex string for encrypting emails and attachments at rest. If this key is not provided, storage encryption will be disabled. You can generate one with:
```bash
openssl rand -hex 32
```
### Storage Configuration
@@ -117,13 +121,14 @@ These variables are used by `docker-compose.yml` to configure the services.
| ------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------- |
| `STORAGE_TYPE` | The storage backend to use (`local` or `s3`). | `local` |
| `BODY_SIZE_LIMIT` | The maximum request body size for uploads. Can be a number in bytes or a string with a unit (e.g., `100M`). | `100M` |
| `STORAGE_LOCAL_ROOT_PATH` | The root path for local file storage. | `/var/data/open-archiver` |
| `STORAGE_LOCAL_ROOT_PATH` | The root path for Open Archiver app data. | `/var/data/open-archiver` |
| `STORAGE_S3_ENDPOINT` | The endpoint for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
| `STORAGE_S3_BUCKET` | The bucket name for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
| `STORAGE_S3_ACCESS_KEY_ID` | The access key ID for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
| `STORAGE_S3_SECRET_ACCESS_KEY` | The secret access key for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
| `STORAGE_S3_REGION` | The region for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
| `STORAGE_S3_FORCE_PATH_STYLE` | Force path-style addressing for S3 (optional). | `false` |
| `STORAGE_ENCRYPTION_KEY` | A 32-byte hex string for AES-256 encryption of files at rest. If not set, files will not be encrypted. | |
#### Security & Authentication

View File

@@ -8,7 +8,7 @@ export const rateLimiter = rateLimit({
max: config.api.rateLimit.max,
keyGenerator: (req, res) => {
// Use the real IP address of the client, even if it's behind a proxy.
// This is safe because we have `app.set('trust proxy', true)` in `server.ts`.
// `app.set('trust proxy', true)` in `server.ts`.
return ipKeyGenerator(req.ip || 'unknown');
},
message: {

View File

@@ -2,9 +2,14 @@ import { StorageConfig } from '@open-archiver/types';
import 'dotenv/config';
const storageType = process.env.STORAGE_TYPE;
const encryptionKey = process.env.STORAGE_ENCRYPTION_KEY;
const openArchiverFolderName = 'open-archiver';
let storageConfig: StorageConfig;
if (encryptionKey && !/^[a-fA-F0-9]{64}$/.test(encryptionKey)) {
throw new Error('STORAGE_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
}
if (storageType === 'local') {
if (!process.env.STORAGE_LOCAL_ROOT_PATH) {
throw new Error('STORAGE_LOCAL_ROOT_PATH is not defined in the environment variables');
@@ -13,6 +18,7 @@ if (storageType === 'local') {
type: 'local',
rootPath: process.env.STORAGE_LOCAL_ROOT_PATH,
openArchiverFolderName: openArchiverFolderName,
encryptionKey: encryptionKey,
};
} else if (storageType === 's3') {
if (
@@ -32,6 +38,7 @@ if (storageType === 'local') {
region: process.env.STORAGE_S3_REGION,
forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true',
openArchiverFolderName: openArchiverFolderName,
encryptionKey: encryptionKey,
};
} else {
throw new Error(`Invalid STORAGE_TYPE: ${storageType}`);

View File

@@ -2,11 +2,22 @@ import { IStorageProvider, StorageConfig } from '@open-archiver/types';
import { LocalFileSystemProvider } from './storage/LocalFileSystemProvider';
import { S3StorageProvider } from './storage/S3StorageProvider';
import { config } from '../config/index';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import { streamToBuffer } from '../helpers/streamToBuffer';
import { Readable } from 'stream';
const ENCRYPTION_PREFIX = Buffer.from('oa_enc_v1::');
export class StorageService implements IStorageProvider {
private provider: IStorageProvider;
private encryptionKey: Buffer | null = null;
private readonly algorithm = 'aes-256-cbc';
constructor(storageConfig: StorageConfig = config.storage) {
if (storageConfig.encryptionKey) {
this.encryptionKey = Buffer.from(storageConfig.encryptionKey, 'hex');
}
switch (storageConfig.type) {
case 'local':
this.provider = new LocalFileSystemProvider(storageConfig);
@@ -19,12 +30,50 @@ export class StorageService implements IStorageProvider {
}
}
put(path: string, content: Buffer | NodeJS.ReadableStream): Promise<void> {
return this.provider.put(path, content);
private async encrypt(content: Buffer): Promise<Buffer> {
if (!this.encryptionKey) {
return content;
}
const iv = randomBytes(16);
const cipher = createCipheriv(this.algorithm, this.encryptionKey, iv);
const encrypted = Buffer.concat([cipher.update(content), cipher.final()]);
return Buffer.concat([ENCRYPTION_PREFIX, iv, encrypted]);
}
get(path: string): Promise<NodeJS.ReadableStream> {
return this.provider.get(path);
private async decrypt(content: Buffer): Promise<Buffer> {
if (!this.encryptionKey) {
return content;
}
const prefix = content.subarray(0, ENCRYPTION_PREFIX.length);
if (!prefix.equals(ENCRYPTION_PREFIX)) {
// File is not encrypted, return as is
return content;
}
try {
const iv = content.subarray(ENCRYPTION_PREFIX.length, ENCRYPTION_PREFIX.length + 16);
const encrypted = content.subarray(ENCRYPTION_PREFIX.length + 16);
const decipher = createDecipheriv(this.algorithm, this.encryptionKey, iv);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
} catch (error) {
// Decryption failed for a file that has the prefix.
// This indicates a corrupted file or a wrong key.
throw new Error('Failed to decrypt file. It may be corrupted or the key is incorrect.');
}
}
async put(path: string, content: Buffer | NodeJS.ReadableStream): Promise<void> {
const buffer =
content instanceof Buffer ? content : await streamToBuffer(content as NodeJS.ReadableStream);
const encryptedContent = await this.encrypt(buffer);
return this.provider.put(path, encryptedContent);
}
async get(path: string): Promise<NodeJS.ReadableStream> {
const stream = await this.provider.get(path);
const buffer = await streamToBuffer(stream);
const decryptedContent = await this.decrypt(buffer);
return Readable.from(decryptedContent);
}
delete(path: string): Promise<void> {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -45,6 +45,7 @@ export interface LocalStorageConfig {
// The absolute root path on the server where the archive will be stored.
rootPath: string;
openArchiverFolderName: string;
encryptionKey?: string;
}
/**
@@ -66,6 +67,7 @@ export interface S3StorageConfig {
// Force path-style addressing, required for MinIO.
forcePathStyle?: boolean;
openArchiverFolderName: string;
encryptionKey?: string;
}
export type StorageConfig = LocalStorageConfig | S3StorageConfig;

File diff suppressed because one or more lines are too long