Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions apps/web/__tests__/vault-validation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Tests for vault payload validation.
*
* The server cannot inspect what it stores, so these checks are the entire
* defence against a malformed or hostile write reaching the table. The
* KDF-floor case matters most: it stops one client creating a weak vault that
* every other client then has to open.
* @module __tests__/vault-validation.test
*/

import { describe, it, expect } from 'vitest';
import {
isUuid,
isSaneBlob,
validateItemPayload,
validateVaultMeta,
MAX_CIPHERTEXT_LENGTH,
MIN_KDF_ITERATIONS,
} from '@/lib/vault-validation';

const validItem = () => ({
id: '3f2504e0-4f89-41d3-9a0c-0305e82c3301',
type: 1,
ciphertext: 'aGVsbG8gd29ybGQ=',
iv: 'YWJjZGVmZ2hpams=',
});

const validMeta = () => ({
kdf: 'pbkdf2-sha256',
iterations: 600000,
salt: 'c2FsdHNhbHQ=',
protectedUserKey: 'a2V5',
protectedUserKeyIv: 'aXY=',
authHash: 'aGFzaA==',
});

describe('isUuid', () => {
it('accepts a v4 uuid', () => {
expect(isUuid('3f2504e0-4f89-41d3-9a0c-0305e82c3301')).toBe(true);
});

it.each([['not-a-uuid'], [''], [null], [42], ['3f2504e04f8941d39a0c0305e82c3301']])(
'rejects %s',
(value) => {
expect(isUuid(value)).toBe(false);
}
);
});

describe('isSaneBlob', () => {
it('accepts base64', () => {
expect(isSaneBlob('aGVsbG8=')).toBe(true);
});

it('rejects non-base64 characters', () => {
expect(isSaneBlob('not base64!')).toBe(false);
expect(isSaneBlob('<script>')).toBe(false);
});

it('rejects anything over the length ceiling', () => {
expect(isSaneBlob('a'.repeat(2000))).toBe(false);
});

it('treats an empty value as missing', () => {
expect(isSaneBlob('')).toBe(false);
expect(isSaneBlob('', { required: false })).toBe(true);
expect(isSaneBlob(undefined, { required: false })).toBe(true);
});
});

describe('validateItemPayload', () => {
it('accepts a well-formed item', () => {
expect(validateItemPayload(validItem())).toBeNull();
});

it('requires a real id', () => {
expect(validateItemPayload({ ...validItem(), id: 'nope' })).toMatch(/valid item id/);
});

it('can skip the id, for a route that takes it from the path', () => {
const { id: _omitted, ...withoutId } = validItem();
expect(validateItemPayload(withoutId, { requireId: false })).toBeNull();
});

it.each([[0], [5], [null], ['login'], [1.5]])('rejects type %s', (type) => {
expect(validateItemPayload({ ...validItem(), type })).toMatch(/Unknown item type/);
});

it('accepts every defined type', () => {
for (const type of [1, 2, 3, 4]) {
expect(validateItemPayload({ ...validItem(), type })).toBeNull();
}
});

it('rejects an empty or non-base64 ciphertext', () => {
expect(validateItemPayload({ ...validItem(), ciphertext: '' })).toMatch(/ciphertext/);
expect(validateItemPayload({ ...validItem(), ciphertext: 'not base64!' })).toMatch(
/ciphertext/
);
});

it('rejects a ciphertext beyond the size ceiling', () => {
const huge = 'a'.repeat(MAX_CIPHERTEXT_LENGTH + 4);
expect(validateItemPayload({ ...validItem(), ciphertext: huge })).toMatch(/ciphertext/);
});

it('rejects a malformed iv', () => {
expect(validateItemPayload({ ...validItem(), iv: '!!' })).toMatch(/iv/);
});

it('rejects a non-object body', () => {
expect(validateItemPayload(null)).toMatch(/Invalid JSON/);
expect(validateItemPayload('a string')).toMatch(/Invalid JSON/);
});
});

describe('validateVaultMeta', () => {
it('accepts well-formed key material', () => {
expect(validateVaultMeta(validMeta())).toBeNull();
});

it('accepts an optional recovery blob', () => {
expect(
validateVaultMeta({ ...validMeta(), recoveryKeyBlob: 'YmxvYg==', recoveryKeyIv: 'aXY=' })
).toBeNull();
});

it('refuses a KDF it does not know', () => {
expect(validateVaultMeta({ ...validMeta(), kdf: 'md5' })).toMatch(/Unsupported KDF/);
});

it('refuses iterations below the floor — the downgrade defence', () => {
expect(validateVaultMeta({ ...validMeta(), iterations: 1 })).toMatch(/below the permitted/);
expect(validateVaultMeta({ ...validMeta(), iterations: MIN_KDF_ITERATIONS - 1 })).toMatch(
/below the permitted/
);
});

it('accepts exactly the floor', () => {
expect(validateVaultMeta({ ...validMeta(), iterations: MIN_KDF_ITERATIONS })).toBeNull();
});

it('refuses a non-integer iteration count', () => {
expect(validateVaultMeta({ ...validMeta(), iterations: '600000' })).toMatch(
/below the permitted/
);
});

it('requires every mandatory blob', () => {
for (const field of ['salt', 'protectedUserKey', 'protectedUserKeyIv', 'authHash']) {
const meta = { ...validMeta(), [field]: '' };
expect(validateVaultMeta(meta)).toMatch(/Malformed key material/);
}
});
});
181 changes: 181 additions & 0 deletions apps/web/app/api/vault/items/[id]/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* PUT /api/vault/items/[id] - Replace an item's ciphertext
* PATCH /api/vault/items/[id] - Move to trash, or restore from it
* DELETE /api/vault/items/[id] - Delete permanently, now
*
* Authentication: session cookie (web) OR Bearer token (extension)
*
* PUT takes the revision the client last read. The database bumps `revision` on
* every update, so a write built on a stale copy matches no row and comes back
* as a 409 rather than silently overwriting whatever another device saved. This
* is the reason vault items are one row each instead of a single blob: two
* devices editing two different passwords must not cost anyone a credential.
*/

import { NextResponse } from 'next/server';
import { corsHeaders, getAuthenticatedUser } from '@/lib/auth-helper';
import { validateItemPayload, isUuid, TRASH_RETENTION_DAYS } from '@/lib/vault-validation';

const METHODS = ['PUT', 'PATCH', 'DELETE', 'OPTIONS'];

export async function OPTIONS(request) {
return new NextResponse(null, { status: 204, headers: corsHeaders(request, METHODS) });
}

/** Shared preamble: auth, id validation, CORS headers. */
async function begin(request, context) {
const headers = corsHeaders(request, METHODS);
const { id } = await context.params;

if (!isUuid(id)) {
return { headers, error: NextResponse.json({ error: 'Invalid item id' }, { status: 400, headers }) };
}

const { user, supabase } = await getAuthenticatedUser(request);
if (!user) {
return { headers, error: NextResponse.json({ error: 'Unauthorized' }, { status: 401, headers }) };
}

return { headers, id, user, supabase };
}

/**
* Body: { type, ciphertext, iv, revision }
*/
export async function PUT(request, context) {
const ctx = await begin(request, context);
if (ctx.error) return ctx.error;
const { headers, id, user, supabase } = ctx;

try {
const body = await request.json().catch(() => null);
const invalid = validateItemPayload({ ...body, id }, { requireId: false });
if (invalid) {
return NextResponse.json({ error: invalid }, { status: 400, headers });
}
if (!Number.isInteger(body.revision) || body.revision < 1) {
return NextResponse.json(
{ error: 'A revision is required to update an item' },
{ status: 400, headers }
);
}

const { data, error } = await supabase
.from('vault_items')
.update({ type: body.type, ciphertext: body.ciphertext, iv: body.iv })
.eq('id', id)
.eq('user_id', user.id)
.eq('revision', body.revision)
.select('id, type, revision, updated_at')
.maybeSingle();

if (error) {
console.error('Vault item update error:', error);
return NextResponse.json({ error: 'Failed to update item' }, { status: 500, headers });
}

if (!data) {
// Either the item is gone or somebody else wrote first. Return the
// current row so the client can merge rather than guess.
const { data: current } = await supabase
.from('vault_items')
.select('id, type, ciphertext, iv, revision, updated_at')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle();

if (!current) {
return NextResponse.json({ error: 'Item not found' }, { status: 404, headers });
}
return NextResponse.json(
{ error: 'Item was modified elsewhere', code: 'REVISION_CONFLICT', item: current },
{ status: 409, headers }
);
}

return NextResponse.json({ item: data }, { headers });
} catch (error) {
console.error('Vault item API error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers });
}
}

/**
* Body: { action: 'trash' | 'restore' }
*/
export async function PATCH(request, context) {
const ctx = await begin(request, context);
if (ctx.error) return ctx.error;
const { headers, id, user, supabase } = ctx;

try {
const body = await request.json().catch(() => null);
const action = body?.action;

if (action !== 'trash' && action !== 'restore') {
return NextResponse.json(
{ error: "action must be 'trash' or 'restore'" },
{ status: 400, headers }
);
}

const patch =
action === 'trash'
? {
deleted_at: new Date().toISOString(),
purge_after: new Date(
Date.now() + TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000
).toISOString(),
}
: { deleted_at: null, purge_after: null };

const { data, error } = await supabase
.from('vault_items')
.update(patch)
.eq('id', id)
.eq('user_id', user.id)
.select('id, revision, deleted_at, purge_after')
.maybeSingle();

if (error) {
console.error('Vault item trash error:', error);
return NextResponse.json({ error: `Failed to ${action} item` }, { status: 500, headers });
}
if (!data) {
return NextResponse.json({ error: 'Item not found' }, { status: 404, headers });
}

return NextResponse.json({ item: data }, { headers });
} catch (error) {
console.error('Vault item API error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers });
}
}

/**
* Permanent deletion. The trash bin is PATCH { action: 'trash' } — this is the
* irreversible one, used by "delete forever" and by emptying the trash.
*/
export async function DELETE(request, context) {
const ctx = await begin(request, context);
if (ctx.error) return ctx.error;
const { headers, id, user, supabase } = ctx;

try {
const { error } = await supabase
.from('vault_items')
.delete()
.eq('id', id)
.eq('user_id', user.id);

if (error) {
console.error('Vault item delete error:', error);
return NextResponse.json({ error: 'Failed to delete item' }, { status: 500, headers });
}

return NextResponse.json({ success: true }, { headers });
} catch (error) {
console.error('Vault item API error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers });
}
}
Loading
Loading