Merge branch 'feat/do-trust-fixes' into feat/desktop-offline

This commit is contained in:
Linus Rath
2026-11-04 03:43:04 +01:00
15 changed files with 474 additions and 23 deletions
+59 -3
View File
@@ -16,8 +16,16 @@ vi.mock('@/lib/stalwart/credentials', () => ({
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled: true } }) },
}));
vi.mock('@/lib/plugin-secrets/known-plugins', () => ({
declaredUserCredential: async () => ({ inject: { type: 'bearer' } }),
}));
vi.mock('@/lib/plugin-secrets/proxy', () => ({
adminCredentialOrigins: async () => [],
adminCredentialStatus: async () => [],
}));
vi.mock('@/lib/plugin-secrets/vault', () => ({
VaultFullError: class extends Error {},
userKey: (user: { username: string; serverUrl: string }) => `${Buffer.from(`${user.username}:${user.serverUrl}`).toString('hex')}0000000000000000`.slice(0, 64),
deleteAllUserCredentials: vi.fn(async (user: unknown) => { deleted.push(user); }),
deleteUserCredentials: vi.fn(async () => {}),
listUserCredentials: vi.fn(async () => []),
@@ -31,10 +39,23 @@ async function remove(query: string, headers: Record<string, string> = { 'sec-fe
}
const EVICTED = 'all=true&username=alice&server=https%3A%2F%2Fmail.example.org';
const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin' };
/** The proof cookie an account gets while signed in, as `name=value`. */
async function proofFor(username: string): Promise<string> {
vi.resetModules();
const { setWipeProof } = await import('@/lib/plugin-secrets/wipe-proof');
const { NextResponse } = await import('next/server');
const answer = NextResponse.json({});
setWipeProof(answer, { username, serverUrl: 'https://mail.example.org' });
const cookie = answer.cookies.getAll()[0];
return `${cookie.name}=${cookie.value}`;
}
beforeEach(() => {
deleted.length = 0;
session.signedIn = false;
vi.stubEnv('SESSION_SECRET', 'wipe-test-session-secret-wipe-test-session-secret');
});
afterEach(() => {
@@ -42,23 +63,58 @@ afterEach(() => {
});
describe('DELETE /api/plugin-secrets?all=true', () => {
it('in the desktop app takes a dropped account by who it was, with no cookie', async () => {
it('in the desktop app takes a dropped account by who it was, with the proof it got while signed in', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await remove(EVICTED)).status).toBe(200);
const answer = await remove(EVICTED, { ...SAME_ORIGIN, cookie: await proofFor('alice') });
expect(answer.status).toBe(200);
expect(deleted).toEqual([{ username: 'alice', serverUrl: 'https://mail.example.org' }]);
expect(answer.headers.get('set-cookie')).toMatch(/Max-Age=0/i);
});
it('in the desktop app won\'t take any name on its own', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await remove(EVICTED)).status).toBe(401);
expect(deleted).toEqual([]);
});
it('in the desktop app won\'t take another account\'s proof, or the signed-in account\'s session, for that name', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
session.signedIn = true;
const bob = await proofFor('bob');
const aliceName = (await proofFor('alice')).split('=')[0];
expect((await remove(EVICTED, { ...SAME_ORIGIN, cookie: `${aliceName}=${bob.split('=').slice(1).join('=')}` })).status).toBe(401);
expect((await remove(EVICTED, { ...SAME_ORIGIN, cookie: `${aliceName}=forged` })).status).toBe(401);
expect(deleted).toEqual([]);
});
it('won\'t take it from another site', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site' })).status).toBe(403);
const cookie = await proofFor('alice');
expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site', cookie })).status).toBe(403);
expect(deleted).toEqual([]);
});
it('gives the proof when an account lists what it has, in the desktop app only', async () => {
session.signedIn = true;
const list = async () => {
vi.resetModules();
const vault = await import('@/lib/plugin-secrets/vault');
vi.mocked(vault.listUserCredentials).mockResolvedValueOnce([{ pluginId: 'p' } as never]);
const { GET } = await import('../route');
return GET(new NextRequest('https://app.local/api/plugin-secrets', { headers: SAME_ORIGIN }));
};
expect((await list()).headers.get('set-cookie')).toBeNull();
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await list()).headers.get('set-cookie')).toMatch(/^jmap_psp_/);
});
it('on an instance deletes nobody\'s, with a cookie or without', async () => {
expect((await remove(EVICTED)).status).toBe(401);
session.signedIn = true;
await remove(EVICTED);
expect(deleted).toEqual([]);
await remove(EVICTED, { ...SAME_ORIGIN, cookie: await proofFor('alice') });
expect(deleted).toEqual([]);
});
it('in the desktop app with a cookie and no name takes the signed-in account', async () => {
+18 -6
View File
@@ -15,6 +15,7 @@ import {
} from '@/lib/plugin-secrets/vault';
import { storedDataUnavailableResponse } from '@/lib/stored-data-errors';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { clearWipeProof, hasWipeProof, setWipeProof } from '@/lib/plugin-secrets/wipe-proof';
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const MAX_SECRET_LENGTH = 4096;
@@ -52,7 +53,9 @@ export async function GET(request: NextRequest) {
const [admin, adminOrigins] = pluginId
? await Promise.all([adminCredentialStatus(pluginId), adminCredentialOrigins(pluginId)])
: [[], []];
return NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE });
const answer = NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE });
if (IS_DESKTOP_BUILD && credentials.length > 0) setWipeProof(answer, user);
return answer;
}
/**
@@ -107,21 +110,28 @@ export async function POST(request: NextRequest) {
if (unavailable) return unavailable;
throw error;
}
return NextResponse.json({ ok: true }, { headers: NO_STORE });
const answer = NextResponse.json({ ok: true }, { headers: NO_STORE });
if (IS_DESKTOP_BUILD) setWipeProof(answer, user);
return answer;
}
/** DELETE /api/plugin-secrets?plugin=<id>[&credential=<id>] - disconnect. */
export async function DELETE(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
// An account dropped earlier, in the desktop app: no cookie left, and this server serves this device alone.
// An account dropped earlier, in the desktop app: no session cookie left, so it
// takes the proof that account got while it had one (lib/plugin-secrets/wipe-proof.ts).
const { searchParams } = request.nextUrl;
const owner = searchParams.get('username');
const server = searchParams.get('server');
if (IS_DESKTOP_BUILD && searchParams.get('all') === 'true' && owner && server) {
const named = { username: owner, serverUrl: server };
if (!hasWipeProof(request, named)) return NextResponse.json({ error: 'Not authenticated' }, { status: 401, headers: NO_STORE });
try {
await deleteAllUserCredentials({ username: owner, serverUrl: server });
return NextResponse.json({ ok: true }, { headers: NO_STORE });
await deleteAllUserCredentials(named);
const answer = NextResponse.json({ ok: true }, { headers: NO_STORE });
clearWipeProof(answer, named);
return answer;
} catch (error) {
const unavailable = storedDataUnavailableResponse(error, NO_STORE);
if (unavailable) return unavailable;
@@ -135,7 +145,9 @@ export async function DELETE(request: NextRequest) {
if (IS_DESKTOP_BUILD && request.nextUrl.searchParams.get('all') === 'true') {
try {
await deleteAllUserCredentials(user);
return NextResponse.json({ ok: true }, { headers: NO_STORE });
const answer = NextResponse.json({ ok: true }, { headers: NO_STORE });
clearWipeProof(answer, user);
return answer;
} catch (error) {
const unavailable = storedDataUnavailableResponse(error, NO_STORE);
if (unavailable) return unavailable;