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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user