mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
feat: Integrity report, allowing users to verify the integrity of archived emails and their attachments.
- When an email is archived, Open Archiver calculates a unique cryptographic signature (a SHA256 hash) for the email's raw `.eml` file and for each of its attachments. These signatures are stored in the database alongside the email's metadata. - The integrity check feature recalculates these signatures for the stored files and compares them to the original signatures stored in the database. This process allows you to verify that the content of your archived emails has not been altered, corrupted, or tampered with since the moment they were archived. - Add docs of Integrity report
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -24,3 +24,9 @@ pnpm-debug.log
|
||||
# Vitepress
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
|
||||
|
||||
# TS
|
||||
tsconfig.tsbuildinfo
|
||||
packages/backend/tsconfig.tsbuildinfo
|
||||
packages/types/tsconfig.tsbuildinfo
|
||||
|
||||
BIN
assets/screenshots/integrity-report.png
Normal file
BIN
assets/screenshots/integrity-report.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
@@ -33,6 +33,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'Get Started', link: '/' },
|
||||
{ text: 'Installation', link: '/user-guides/installation' },
|
||||
{ text: 'Email Integrity Check', link: '/user-guides/integrity-check' },
|
||||
{
|
||||
text: 'Email Providers',
|
||||
link: '/user-guides/email-providers/',
|
||||
@@ -91,6 +92,7 @@ export default defineConfig({
|
||||
{ text: 'Archived Email', link: '/api/archived-email' },
|
||||
{ text: 'Dashboard', link: '/api/dashboard' },
|
||||
{ text: 'Ingestion', link: '/api/ingestion' },
|
||||
{ text: 'Integrity Check', link: '/api/integrity' },
|
||||
{ text: 'Search', link: '/api/search' },
|
||||
{ text: 'Storage', link: '/api/storage' },
|
||||
],
|
||||
|
||||
51
docs/api/integrity.md
Normal file
51
docs/api/integrity.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Integrity Check API
|
||||
|
||||
The Integrity Check API provides an endpoint to verify the cryptographic hash of an archived email and its attachments against the stored values in the database. This allows you to ensure that the stored files have not been tampered with or corrupted since they were archived.
|
||||
|
||||
## Check Email Integrity
|
||||
|
||||
Verifies the integrity of a specific archived email and all of its associated attachments.
|
||||
|
||||
- **URL:** `/api/v1/integrity/:id`
|
||||
- **Method:** `GET`
|
||||
- **URL Params:**
|
||||
- `id=[string]` (required) - The UUID of the archived email to check.
|
||||
- **Permissions:** `read:archive`
|
||||
- **Success Response:**
|
||||
- **Code:** 200 OK
|
||||
- **Content:** `IntegrityCheckResult[]`
|
||||
|
||||
### Response Body `IntegrityCheckResult`
|
||||
|
||||
An array of objects, each representing the result of an integrity check for a single file (either the email itself or an attachment).
|
||||
|
||||
| Field | Type | Description |
|
||||
| :--------- | :------------------------ | :-------------------------------------------------------------------------- |
|
||||
| `type` | `'email' \| 'attachment'` | The type of the file being checked. |
|
||||
| `id` | `string` | The UUID of the email or attachment. |
|
||||
| `filename` | `string` (optional) | The filename of the attachment. This field is only present for attachments. |
|
||||
| `isValid` | `boolean` | `true` if the current hash matches the stored hash, otherwise `false`. |
|
||||
| `reason` | `string` (optional) | A reason for the failure. Only present if `isValid` is `false`. |
|
||||
|
||||
### Example Response
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "email",
|
||||
"id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||
"isValid": true
|
||||
},
|
||||
{
|
||||
"type": "attachment",
|
||||
"id": "b2c3d4e5-f6a7-8901-2345-67890abcdef1",
|
||||
"filename": "document.pdf",
|
||||
"isValid": false,
|
||||
"reason": "Stored hash does not match current hash."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- **Error Response:**
|
||||
- **Code:** 404 Not Found
|
||||
- **Content:** `{ "message": "Archived email not found" }`
|
||||
37
docs/user-guides/integrity-check.md
Normal file
37
docs/user-guides/integrity-check.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Email Integrity Check
|
||||
|
||||
Open Archiver allows you to verify the integrity of your archived emails and their attachments. This guide explains how the integrity check works and what the results mean.
|
||||
|
||||
## How It Works
|
||||
|
||||
When an email is archived, Open Archiver calculates a unique cryptographic signature (a SHA256 hash) for the email's raw `.eml` file and for each of its attachments. These signatures are stored in the database alongside the email's metadata.
|
||||
|
||||
The integrity check feature recalculates these signatures for the stored files and compares them to the original signatures stored in the database. This process allows you to verify that the content of your archived emails has not been altered, corrupted, or tampered with since the moment they were archived.
|
||||
|
||||
## The Integrity Report
|
||||
|
||||
When you view an email in the Open Archiver interface, an integrity report is automatically generated and displayed. This report provides a clear, at-a-glance status for the email file and each of its attachments.
|
||||
|
||||
### Statuses
|
||||
|
||||
- **Valid (Green Badge):** A "Valid" status means that the current signature of the file matches the original signature stored in the database. This is the expected status and indicates that the file's integrity is intact.
|
||||
|
||||
- **Invalid (Red Badge):** An "Invalid" status means that the current signature of the file does _not_ match the original signature. This indicates that the file's content has changed since it was archived.
|
||||
|
||||
### Reasons for an "Invalid" Status
|
||||
|
||||
If a file is marked as "Invalid," you can hover over the badge to see a reason for the failure. Common reasons include:
|
||||
|
||||
- **Stored hash does not match current hash:** This is the most common reason and indicates that the file's content has been modified. This could be due to accidental changes, data corruption, or unauthorized tampering.
|
||||
|
||||
- **Could not read attachment file from storage:** This message indicates that the file could not be read from its storage location. This could be due to a storage system issue, a file permission problem, or because the file has been deleted.
|
||||
|
||||
## What to Do If an Integrity Check Fails
|
||||
|
||||
If you encounter an "Invalid" status for an email or attachment, it is important to investigate the issue. Here are some steps you can take:
|
||||
|
||||
1. **Check Storage:** Verify that the file exists in its storage location and that its permissions are correct.
|
||||
2. **Review Audit Logs:** If you have audit logging enabled, review the logs for any unauthorized access or modifications to the file.
|
||||
3. **Restore from Backup:** If you suspect data corruption, you may need to restore the affected file from a backup.
|
||||
|
||||
The integrity check feature is a crucial tool for ensuring the long-term reliability and trustworthiness of your email archive. By regularly monitoring the integrity of your archived data, you can be confident that your records are accurate and complete.
|
||||
29
packages/backend/src/api/controllers/integrity.controller.ts
Normal file
29
packages/backend/src/api/controllers/integrity.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { IntegrityService } from '../../services/IntegrityService';
|
||||
import { z } from 'zod';
|
||||
|
||||
const checkIntegritySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export class IntegrityController {
|
||||
private integrityService = new IntegrityService();
|
||||
|
||||
public checkIntegrity = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = checkIntegritySchema.parse(req.params);
|
||||
const results = await this.integrityService.checkEmailIntegrity(id);
|
||||
res.status(200).json(results);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: req.t('api.requestBodyInvalid'), errors: error.message });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Archived email not found') {
|
||||
return res.status(404).json({ message: req.t('errors.notFound') });
|
||||
}
|
||||
res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
16
packages/backend/src/api/routes/integrity.routes.ts
Normal file
16
packages/backend/src/api/routes/integrity.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { IntegrityController } from '../controllers/integrity.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const integrityRoutes = (authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
const controller = new IntegrityController();
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/:id', requirePermission('read', 'archive'), controller.checkIntegrity);
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { createUploadRouter } from './routes/upload.routes';
|
||||
import { createUserRouter } from './routes/user.routes';
|
||||
import { createSettingsRouter } from './routes/settings.routes';
|
||||
import { apiKeyRoutes } from './routes/api-key.routes';
|
||||
import { integrityRoutes } from './routes/integrity.routes';
|
||||
import { AuthService } from '../services/AuthService';
|
||||
import { AuditService } from '../services/AuditService';
|
||||
import { UserService } from '../services/UserService';
|
||||
@@ -109,6 +110,7 @@ export async function createServer(modules: ArchiverModule[] = []): Promise<Expr
|
||||
const userRouter = createUserRouter(authService);
|
||||
const settingsRouter = createSettingsRouter(authService);
|
||||
const apiKeyRouter = apiKeyRoutes(authService);
|
||||
const integrityRouter = integrityRoutes(authService);
|
||||
// upload route is added before middleware because it doesn't use the json middleware.
|
||||
app.use(`/${config.api.version}/upload`, uploadRouter);
|
||||
|
||||
@@ -145,6 +147,7 @@ export async function createServer(modules: ArchiverModule[] = []): Promise<Expr
|
||||
|
||||
app.use(`/${config.api.version}/settings`, settingsRouter);
|
||||
app.use(`/${config.api.version}/api-keys`, apiKeyRouter);
|
||||
app.use(`/${config.api.version}/integrity`, integrityRouter);
|
||||
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
|
||||
93
packages/backend/src/services/IntegrityService.ts
Normal file
93
packages/backend/src/services/IntegrityService.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { db } from '../database';
|
||||
import { archivedEmails, emailAttachments } from '../database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { StorageService } from './StorageService';
|
||||
import { createHash } from 'crypto';
|
||||
import { logger } from '../config/logger';
|
||||
import type { IntegrityCheckResult } from '@open-archiver/types';
|
||||
import { streamToBuffer } from '../helpers/streamToBuffer';
|
||||
|
||||
export class IntegrityService {
|
||||
private storageService = new StorageService();
|
||||
|
||||
public async checkEmailIntegrity(emailId: string): Promise<IntegrityCheckResult[]> {
|
||||
const results: IntegrityCheckResult[] = [];
|
||||
|
||||
// 1. Fetch the archived email
|
||||
const email = await db.query.archivedEmails.findFirst({
|
||||
where: eq(archivedEmails.id, emailId),
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
throw new Error('Archived email not found');
|
||||
}
|
||||
|
||||
// 2. Check the email's integrity
|
||||
const emailStream = await this.storageService.get(email.storagePath);
|
||||
const emailBuffer = await streamToBuffer(emailStream);
|
||||
const currentEmailHash = createHash('sha256').update(emailBuffer).digest('hex');
|
||||
|
||||
if (currentEmailHash === email.storageHashSha256) {
|
||||
results.push({ type: 'email', id: email.id, isValid: true });
|
||||
} else {
|
||||
results.push({
|
||||
type: 'email',
|
||||
id: email.id,
|
||||
isValid: false,
|
||||
reason: 'Stored hash does not match current hash.',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. If the email has attachments, check them
|
||||
if (email.hasAttachments) {
|
||||
const emailAttachmentsRelations = await db.query.emailAttachments.findMany({
|
||||
where: eq(emailAttachments.emailId, emailId),
|
||||
with: {
|
||||
attachment: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const relation of emailAttachmentsRelations) {
|
||||
const attachment = relation.attachment;
|
||||
try {
|
||||
const attachmentStream = await this.storageService.get(attachment.storagePath);
|
||||
const attachmentBuffer = await streamToBuffer(attachmentStream);
|
||||
const currentAttachmentHash = createHash('sha256')
|
||||
.update(attachmentBuffer)
|
||||
.digest('hex');
|
||||
|
||||
if (currentAttachmentHash === attachment.contentHashSha256) {
|
||||
results.push({
|
||||
type: 'attachment',
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
isValid: true,
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
type: 'attachment',
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
isValid: false,
|
||||
reason: 'Stored hash does not match current hash.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ attachmentId: attachment.id, error },
|
||||
'Failed to read attachment from storage for integrity check.'
|
||||
);
|
||||
results.push({
|
||||
type: 'attachment',
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
isValid: false,
|
||||
reason: 'Could not read attachment file from storage.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -7,7 +7,8 @@
|
||||
"password": "Passwort"
|
||||
},
|
||||
"common": {
|
||||
"working": "Arbeiten"
|
||||
"working": "Arbeiten",
|
||||
"read_docs": "Dokumentation lesen"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archiv",
|
||||
@@ -32,7 +33,14 @@
|
||||
"deleting": "Löschen",
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"not_found": "E-Mail nicht gefunden."
|
||||
"not_found": "E-Mail nicht gefunden.",
|
||||
"integrity_report": "Integritätsbericht",
|
||||
"email_eml": "E-Mail (.eml)",
|
||||
"valid": "Gültig",
|
||||
"invalid": "Ungültig",
|
||||
"integrity_check_failed_title": "Integritätsprüfung fehlgeschlagen",
|
||||
"integrity_check_failed_message": "Die Integrität der E-Mail und ihrer Anhänge konnte nicht überprüft werden.",
|
||||
"integrity_report_description": "Dieser Bericht überprüft, ob der Inhalt Ihrer archivierten E-Mails nicht verändert wurde."
|
||||
},
|
||||
"ingestions": {
|
||||
"title": "Erfassungsquellen",
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"password": "Password"
|
||||
},
|
||||
"common": {
|
||||
"working": "Working"
|
||||
"working": "Working",
|
||||
"read_docs": "Read docs"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archive",
|
||||
@@ -32,7 +33,14 @@
|
||||
"deleting": "Deleting",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"not_found": "Email not found."
|
||||
"not_found": "Email not found.",
|
||||
"integrity_report": "Integrity Report",
|
||||
"email_eml": "Email (.eml)",
|
||||
"valid": "Valid",
|
||||
"invalid": "Invalid",
|
||||
"integrity_check_failed_title": "Integrity Check Failed",
|
||||
"integrity_check_failed_message": "Could not verify the integrity of the email and its attachments.",
|
||||
"integrity_report_description": "This report verifies that the content of your archived emails has not been altered."
|
||||
},
|
||||
"ingestions": {
|
||||
"title": "Ingestion Sources",
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
import { api } from '$lib/server/api';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { ArchivedEmail } from '@open-archiver/types';
|
||||
import type { ArchivedEmail, IntegrityCheckResult } from '@open-archiver/types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
try {
|
||||
const { id } = event.params;
|
||||
const response = await api(`/archived-emails/${id}`, event);
|
||||
const responseText = await response.json();
|
||||
if (!response.ok) {
|
||||
|
||||
const [emailResponse, integrityResponse] = await Promise.all([
|
||||
api(`/archived-emails/${id}`, event),
|
||||
api(`/integrity/${id}`, event),
|
||||
]);
|
||||
|
||||
if (!emailResponse.ok) {
|
||||
const responseText = await emailResponse.json();
|
||||
return error(
|
||||
response.status,
|
||||
emailResponse.status,
|
||||
responseText.message || 'You do not have permission to read this email.'
|
||||
);
|
||||
}
|
||||
const email: ArchivedEmail = responseText;
|
||||
|
||||
if (!integrityResponse.ok) {
|
||||
const responseText = await integrityResponse.json();
|
||||
return error(
|
||||
integrityResponse.status,
|
||||
responseText.message || 'Failed to perform integrity check.'
|
||||
);
|
||||
}
|
||||
|
||||
const email: ArchivedEmail = await emailResponse.json();
|
||||
const integrityReport: IntegrityCheckResult[] = await integrityResponse.json();
|
||||
|
||||
return {
|
||||
email,
|
||||
integrityReport,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load archived email:', error);
|
||||
} catch (e) {
|
||||
console.error('Failed to load archived email:', e);
|
||||
return {
|
||||
email: null,
|
||||
integrityReport: [],
|
||||
error: 'Failed to load email',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,9 +11,14 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
|
||||
import { t } from '$lib/translations';
|
||||
import { ShieldCheck, ShieldAlert, AlertTriangle } from 'lucide-svelte';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let email = $derived(data.email);
|
||||
let integrityReport = $derived(data.integrityReport);
|
||||
let isDeleteDialogOpen = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
@@ -185,6 +190,77 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{#if integrityReport && integrityReport.length > 0}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{$t('app.archive.integrity_report')}</Card.Title>
|
||||
<Card.Description>
|
||||
<span class="mt-1">
|
||||
{$t('app.archive.integrity_report_description')}
|
||||
<a
|
||||
href="https://docs.openarchiver.com/user-guides/integrity-check.html"
|
||||
target="_blank"
|
||||
class="text-primary underline underline-offset-2"
|
||||
>{$t('app.common.read_docs')}</a
|
||||
>.
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-2">
|
||||
<ul class="space-y-2">
|
||||
{#each integrityReport as item}
|
||||
<li class="flex items-center justify-between">
|
||||
<div class="flex min-w-0 flex-row items-center space-x-2">
|
||||
{#if item.isValid}
|
||||
<ShieldCheck
|
||||
class="h-4 w-4 flex-shrink-0 text-green-500"
|
||||
/>
|
||||
{:else}
|
||||
<ShieldAlert
|
||||
class="h-4 w-4 flex-shrink-0 text-red-500"
|
||||
/>
|
||||
{/if}
|
||||
<div class="min-w-0 max-w-64">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{#if item.type === 'email'}
|
||||
{$t('app.archive.email_eml')}
|
||||
{:else}
|
||||
{item.filename}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if item.isValid}
|
||||
<Badge variant="default" class="bg-green-500"
|
||||
>{$t('app.archive.valid')}</Badge
|
||||
>
|
||||
{:else}
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger>
|
||||
<Badge variant="destructive" class="cursor-help"
|
||||
>{$t('app.archive.invalid')}</Badge
|
||||
>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Content class="w-80 bg-gray-50 text-red-500">
|
||||
<p>{item.reason}</p>
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Root>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<Alert.Root variant="destructive">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
<Alert.Title>{$t('app.archive.integrity_check_failed_title')}</Alert.Title>
|
||||
<Alert.Description>
|
||||
{$t('app.archive.integrity_check_failed_message')}
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#if email.thread && email.thread.length > 1}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from './iam.types';
|
||||
export * from './system.types';
|
||||
export * from './audit-log.types';
|
||||
export * from './audit-log.enums';
|
||||
export * from './integrity.types';
|
||||
|
||||
7
packages/types/src/integrity.types.ts
Normal file
7
packages/types/src/integrity.types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface IntegrityCheckResult {
|
||||
type: 'email' | 'attachment';
|
||||
id: string;
|
||||
filename?: string;
|
||||
isValid: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user