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
+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 },
);
}
}