3.14 Password Vault encryption AES-256-GCM server-side

- Created src/core/crypto/ with AES-256-GCM encrypt/decrypt (PBKDF2 key derivation)
- Created /api/vault route: CRUD with server-side password encryption
- PATCH /api/vault migration endpoint to re-encrypt legacy plaintext passwords
- Rewrote use-vault hook to use dedicated /api/vault instead of generic storage
- Updated UI: amber 'not encrypted' warning  green 'encrypted' badge
- Added ENCRYPTION_SECRET env var to docker-compose.yml and stack.env
- Module version bumped to 0.2.0
This commit is contained in:
AI Assistant
2026-02-28 04:12:44 +02:00
parent f0b3659247
commit 85bdb59da4
10 changed files with 366 additions and 59 deletions
+3 -3
View File
@@ -390,12 +390,12 @@
- **Versionare Fișier (Backup):** La fiecare modificare a fișierului `Tags.txt` din ArchiTools, sistemul trebuie să creeze automat un backup al versiunii anterioare (ex: `Tags_backup_YYYYMMDD_HHMMSS.txt`) în același folder, pentru a preveni pierderea accidentală a structurii de tag-uri.
- **Validare Ierarhie:** Asigurarea că tag-urile adăugate respectă formatul ierarhic cerut de ManicTime (ex: `Proiect, Faza, Activitate`).
### 3.14 `[ARCHITECTURE]` Storage & Securitate
### 3.14 `[ARCHITECTURE]` Storage & Securitate
**Cerințe noi:**
- **Migrare Storage (Prioritate):** Devansarea migrării de la `localStorage` la o soluție robustă (MinIO pentru fișiere și o bază de date reală, ex: PostgreSQL/Prisma) pentru a asigura persistența și partajarea datelor între toți utilizatorii.
- **Criptare Parole:** În modulul Password Vault, parolele trebuie criptate real în baza de date (nu doar ascunse în UI), eliminând warning-ul actual de securitate.
- **Migrare Storage (Prioritate):** PostgreSQL via Prisma — realizat anterior (DatabaseStorageAdapter).
- **Criptare Parole:** ✅ AES-256-GCM server-side encryption. Dedicated `/api/vault` route, `src/core/crypto/` service, ENCRYPTION_SECRET env var. Legacy plaintext auto-detected at decrypt. PATCH migration endpoint.
- **Integrare Passbolt (Wishlist):** Studierea posibilității de a lega Password Vault-ul din ArchiTools direct de instanța voastră de Passbolt (via API), pentru a avea un singur "source of truth" securizat pentru parole.
### 3.15 `[BUSINESS]` AI Tools — Extindere și Integrare
+2
View File
@@ -29,5 +29,7 @@ services:
- AUTHENTIK_CLIENT_ID=V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi
- AUTHENTIK_CLIENT_SECRET=TMeewkusUro0hQ2DMwS0Z5lNpNMdmziO9WXywNAGlK3Y6Y8HYULZBEtMtm53lioIkszWbpPRQcv1cxHMtwftMvsaSnbliDsL1f707wmUJhMFKjeZ0ypIFKFG4dJkp7Jr
- AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
# Vault encryption
- ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256
labels:
- "com.centurylinklabs.watchtower.enable=true"
+198
View File
@@ -0,0 +1,198 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { Prisma } from "@prisma/client";
import { encrypt, decrypt, isEncrypted } from "@/core/crypto";
const NAMESPACE = "password-vault";
const KEY_PREFIX = "vault:";
interface VaultEntryData {
id: string;
label: string;
username: string;
email: string;
password: string;
url: string;
category: string;
company: string;
customFields: { key: string; value: string }[];
notes: string;
tags: string[];
visibility: string;
createdAt: string;
updatedAt: string;
}
/** Decrypt the password field in a vault entry */
function decryptEntry(entry: VaultEntryData): VaultEntryData {
return {
...entry,
password: entry.password ? decrypt(entry.password) : "",
};
}
/** Encrypt the password field in a vault entry */
function encryptEntry(entry: VaultEntryData): VaultEntryData {
return {
...entry,
password: entry.password ? encrypt(entry.password) : "",
};
}
/**
* GET /api/vault — List all vault entries (passwords decrypted)
* GET /api/vault?id=xxx — Get single entry (password decrypted)
*/
export async function GET(request: NextRequest) {
const id = request.nextUrl.searchParams.get("id");
try {
if (id) {
const item = await prisma.keyValueStore.findUnique({
where: {
namespace_key: { namespace: NAMESPACE, key: `${KEY_PREFIX}${id}` },
},
});
if (!item || !item.value) {
return NextResponse.json({ entry: null });
}
const entry = decryptEntry(item.value as unknown as VaultEntryData);
return NextResponse.json({ entry });
}
// List all
const items = await prisma.keyValueStore.findMany({
where: { namespace: NAMESPACE },
select: { key: true, value: true },
});
const entries: VaultEntryData[] = [];
for (const item of items) {
if (item.key.startsWith(KEY_PREFIX) && item.value) {
try {
entries.push(decryptEntry(item.value as unknown as VaultEntryData));
} catch {
// If decryption fails, return the entry as-is (migration fallback)
entries.push(item.value as unknown as VaultEntryData);
}
}
}
return NextResponse.json({ entries });
} catch (error) {
console.error("Vault GET error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* POST /api/vault — Create or update a vault entry (password encrypted before storage)
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const entry = body.entry as VaultEntryData;
if (!entry?.id) {
return NextResponse.json(
{ error: "Entry with id is required" },
{ status: 400 },
);
}
const encrypted = encryptEntry(entry);
const key = `${KEY_PREFIX}${entry.id}`;
await prisma.keyValueStore.upsert({
where: { namespace_key: { namespace: NAMESPACE, key } },
update: { value: encrypted as unknown as Prisma.InputJsonValue },
create: {
namespace: NAMESPACE,
key,
value: encrypted as unknown as Prisma.InputJsonValue,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Vault POST error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* DELETE /api/vault?id=xxx — Delete a vault entry
*/
export async function DELETE(request: NextRequest) {
const id = request.nextUrl.searchParams.get("id");
if (!id) {
return NextResponse.json(
{ error: "id parameter is required" },
{ status: 400 },
);
}
try {
await prisma.keyValueStore
.delete({
where: {
namespace_key: { namespace: NAMESPACE, key: `${KEY_PREFIX}${id}` },
},
})
.catch(() => {
/* Ignore if not found */
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Vault DELETE error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* PATCH /api/vault — Re-encrypt all existing entries (migration endpoint)
* Call once after setting ENCRYPTION_SECRET to encrypt legacy plaintext passwords.
*/
export async function PATCH() {
try {
const items = await prisma.keyValueStore.findMany({
where: { namespace: NAMESPACE },
select: { key: true, value: true },
});
let migrated = 0;
for (const item of items) {
if (!item.key.startsWith(KEY_PREFIX) || !item.value) continue;
const entry = item.value as unknown as VaultEntryData;
// Skip if already encrypted
if (entry.password && isEncrypted(entry.password)) continue;
const encrypted = encryptEntry(entry);
await prisma.keyValueStore.update({
where: { namespace_key: { namespace: NAMESPACE, key: item.key } },
data: { value: encrypted as unknown as Prisma.InputJsonValue },
});
migrated++;
}
return NextResponse.json({ success: true, migrated });
} catch (error) {
console.error("Vault PATCH (migration) error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Server-side encryption service using AES-256-GCM.
* Only import this in server-side code (API routes).
*
* Format: enc:v1:<iv-hex>:<authTag-hex>:<ciphertext-hex>
*/
import {
createCipheriv,
createDecipheriv,
randomBytes,
pbkdf2Sync,
} from "crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12; // 96 bits recommended for GCM
const TAG_LENGTH = 16; // 128 bits
const PREFIX = "enc:v1:";
/** Derive a 256-bit key from the secret using PBKDF2 */
function deriveKey(secret: string): Buffer {
// Fixed salt — acceptable because the secret itself is high-entropy
const salt = Buffer.from("architools-vault-v1", "utf8");
return pbkdf2Sync(secret, salt, 100_000, 32, "sha256");
}
function getKey(): Buffer {
const secret = process.env.ENCRYPTION_SECRET;
if (!secret || secret.length < 16) {
throw new Error(
"ENCRYPTION_SECRET environment variable must be set (min 16 chars)",
);
}
return deriveKey(secret);
}
/** Encrypt a plaintext string. Returns prefixed format. */
export function encrypt(plaintext: string): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, {
authTagLength: TAG_LENGTH,
});
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag().toString("hex");
return `${PREFIX}${iv.toString("hex")}:${authTag}:${encrypted}`;
}
/** Decrypt a previously encrypted string. Handles both encrypted and plaintext (migration). */
export function decrypt(value: string): string {
// If not encrypted (legacy plaintext), return as-is
if (!value.startsWith(PREFIX)) {
return value;
}
const key = getKey();
const parts = value.slice(PREFIX.length).split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted format");
}
const [ivHex, authTagHex, ciphertext] = parts;
if (!ivHex || !authTagHex || !ciphertext) {
throw new Error("Invalid encrypted format: missing parts");
}
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const decipher = createDecipheriv(ALGORITHM, key, iv, {
authTagLength: TAG_LENGTH,
});
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
/** Check if a value is already encrypted */
export function isEncrypted(value: string): boolean {
return value.startsWith(PREFIX);
}
@@ -169,9 +169,9 @@ export function PasswordVaultModule() {
return (
<div className="space-y-6">
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate.
Folosiți un manager de parole dedicat pentru date sensibile.
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-2 text-xs text-emerald-700 dark:text-emerald-400">
Parolele sunt criptate (AES-256-GCM) pe server înainte de stocare.
Datele sunt protejate la rest în baza de date.
</div>
{/* Stats */}
+13 -12
View File
@@ -1,17 +1,18 @@
import type { ModuleConfig } from '@/core/module-registry/types';
import type { ModuleConfig } from "@/core/module-registry/types";
export const passwordVaultConfig: ModuleConfig = {
id: 'password-vault',
name: 'Seif Parole',
description: 'Manager securizat de parole și credențiale cu criptare locală',
icon: 'lock',
route: '/password-vault',
category: 'operations',
featureFlag: 'module.password-vault',
visibility: 'admin',
version: '0.1.0',
id: "password-vault",
name: "Seif Parole",
description:
"Manager securizat de parole și credențiale cu criptare AES-256-GCM",
icon: "lock",
route: "/password-vault",
category: "operations",
featureFlag: "module.password-vault",
visibility: "admin",
version: "0.2.0",
dependencies: [],
storageNamespace: 'password-vault',
storageNamespace: "password-vault",
navOrder: 11,
tags: ['parole', 'securitate', 'credențiale'],
tags: ["parole", "securitate", "credențiale", "criptare"],
};
+30 -19
View File
@@ -1,19 +1,19 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type { VaultEntry, VaultEntryCategory } from "../types";
const PREFIX = "vault:";
export interface VaultFilters {
search: string;
category: VaultEntryCategory | "all";
}
/**
* Vault hook — uses dedicated /api/vault endpoint with server-side encryption.
* Passwords are encrypted at rest (AES-256-GCM) and decrypted only on read.
*/
export function useVault() {
const storage = useStorage("password-vault");
const [entries, setEntries] = useState<VaultEntry[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<VaultFilters>({
@@ -23,17 +23,18 @@ export function useVault() {
const refresh = useCallback(async () => {
setLoading(true);
const all = await storage.exportAll();
const results: VaultEntry[] = [];
for (const [key, value] of Object.entries(all)) {
if (key.startsWith(PREFIX) && value) {
results.push(value as VaultEntry);
}
}
try {
const res = await fetch("/api/vault");
const data = await res.json();
const results = (data.entries ?? []) as VaultEntry[];
results.sort((a, b) => a.label.localeCompare(b.label));
setEntries(results);
} catch (err) {
console.error("Failed to load vault entries:", err);
} finally {
setLoading(false);
}, [storage]);
}
}, []);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => {
@@ -49,36 +50,46 @@ export function useVault() {
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${entry.id}`, entry);
await fetch("/api/vault", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry }),
});
await refresh();
return entry;
},
[storage, refresh],
[refresh],
);
const updateEntry = useCallback(
async (id: string, updates: Partial<VaultEntry>) => {
const existing = entries.find((e) => e.id === id);
if (!existing) return;
const updated = {
const updated: VaultEntry = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated);
await fetch("/api/vault", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry: updated }),
});
await refresh();
},
[storage, refresh, entries],
[refresh, entries],
);
const removeEntry = useCallback(
async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await fetch(`/api/vault?id=${encodeURIComponent(id)}`, {
method: "DELETE",
});
await refresh();
},
[storage, refresh],
[refresh],
);
const updateFilter = useCallback(
@@ -122,8 +122,9 @@ export function WordTemplatesModule() {
null,
);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [revisingTemplate, setRevisingTemplate] =
useState<WordTemplate | null>(null);
const [revisingTemplate, setRevisingTemplate] = useState<WordTemplate | null>(
null,
);
const [revisionUrl, setRevisionUrl] = useState("");
const [revisionNotes, setRevisionNotes] = useState("");
const [historyTemplate, setHistoryTemplate] = useState<WordTemplate | null>(
@@ -172,7 +173,12 @@ export function WordTemplatesModule() {
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Word/Excel</p>
<p className="text-2xl font-bold">
{allTemplates.filter((t) => (t.fileType ?? "docx") === "docx" || t.fileType === "xlsx").length}
{
allTemplates.filter(
(t) =>
(t.fileType ?? "docx") === "docx" || t.fileType === "xlsx",
).length
}
</p>
</CardContent>
</Card>
@@ -180,7 +186,11 @@ export function WordTemplatesModule() {
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">DWG/Archicad</p>
<p className="text-2xl font-bold">
{allTemplates.filter((t) => t.fileType === "dwg" || t.fileType === "archicad").length}
{
allTemplates.filter(
(t) => t.fileType === "dwg" || t.fileType === "archicad",
).length
}
</p>
</CardContent>
</Card>
@@ -188,7 +198,10 @@ export function WordTemplatesModule() {
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Cu versiuni</p>
<p className="text-2xl font-bold">
{allTemplates.filter((t) => (t.versionHistory ?? []).length > 0).length}
{
allTemplates.filter((t) => (t.versionHistory ?? []).length > 0)
.length
}
</p>
</CardContent>
</Card>
@@ -431,9 +444,7 @@ export function WordTemplatesModule() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Revizie nouă {revisingTemplate?.name}
</DialogTitle>
<DialogTitle>Revizie nouă {revisingTemplate?.name}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
@@ -546,10 +557,7 @@ export function WordTemplatesModule() {
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setHistoryTemplate(null)}
>
<Button variant="outline" onClick={() => setHistoryTemplate(null)}>
Închide
</Button>
</DialogFooter>
@@ -726,13 +734,13 @@ function TemplateForm({
<SelectValue />
</SelectTrigger>
<SelectContent>
{(
Object.keys(FILE_TYPE_LABELS) as TemplateFileType[]
).map((ft) => (
{(Object.keys(FILE_TYPE_LABELS) as TemplateFileType[]).map(
(ft) => (
<SelectItem key={ft} value={ft}>
{FILE_TYPE_LABELS[ft]}
</SelectItem>
))}
),
)}
</SelectContent>
</Select>
</div>
@@ -112,7 +112,8 @@ export function useTemplates() {
updatedAt: now,
};
if (notes) {
updated.versionHistory[updated.versionHistory.length - 1]!.notes = notes;
updated.versionHistory[updated.versionHistory.length - 1]!.notes =
notes;
}
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
+3
View File
@@ -23,3 +23,6 @@ NEXTAUTH_SECRET="8IL9Kpipj0EZwZPNvekbNRPhV6a2/UY4cGVzE3n0pUY="
AUTHENTIK_CLIENT_ID="V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi"
AUTHENTIK_CLIENT_SECRET="TMeewkusUro0hQ2DMwS0Z5lNpNMdmziO9WXywNAGlK3Y6Y8HYULZBEtMtm53lioIkszWbpPRQcv1cxHMtwftMvsaSnbliDsL1f707wmUJhMFKjeZ0ypIFKFG4dJkp7Jr"
AUTHENTIK_ISSUER="https://auth.beletage.ro/application/o/architools/"
# Vault encryption (AES-256-GCM)
ENCRYPTION_SECRET="ArchiTools-Vault-2025!SecureKey@AES256"