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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ is never transmitted, never stored, and cannot be recovered.
| Google scope | `drive.appdata` (the app's own hidden folder) + email/profile. `drive.file` — files this app created or that the user picked, and nothing else — is asked for only if you move the vault into a folder of your own, or open one shared with you |
| Sharing | The vault's data key wrapped to a recipient's ECDH public key (ephemeral ECDH + HKDF + AES-GCM). The invite code carries no secret; revoking rotates the data key so a kept copy dies |
| OAuth token | Short-lived access token, held in memory only |
| Signing out | *Sair deste aparelho* removes everything of the person's from the browser — vault cache, stored key, encrypted attachment cache, invite identity, account, shared vaults, recent types — and revokes the token. Pending edits are synced first, and it refuses outright to delete a vault that was never uploaded, since that is the only copy. Configuration (client ids) and looks (theme) stay |
| Clipboard | Automatic clearing (default: 30s) after copying a secret; a wipe that came due while the app was in the background runs as soon as it is visible again — a background tab cannot touch the clipboard |
| CSP | `default-src 'none'` with a minimal allow-list; no inline scripts; iframe blocked |
| Search | Never indexes secret values — only name, description, tags and non-sensitive fields |
Expand Down
16 changes: 12 additions & 4 deletions public/privacidade.html
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
<body>
<main>
<h1>Política de privacidade</h1>
<p class="date">eQuantic Keeper · em vigor desde 1 de setembro de 2026</p>
<p class="date">eQuantic Keeper · em vigor desde 29 de setembro de 2026</p>

<div class="card">
<p>
Expand Down Expand Up @@ -207,7 +207,12 @@ <h2>O que não fazemos</h2>
<h2>Como apagar tudo</h2>
<ul>
<li>
<strong>Neste aparelho:</strong> Configurações → Avançado → “Apagar deste dispositivo”.
<strong>Neste aparelho:</strong> o botão <strong>Sair deste aparelho</strong>, ao lado da sua conta na
barra lateral (ou na tela de desbloqueio). Ele apaga deste navegador tudo o que é seu: o cofre, a chave
guardada, os anexos cifrados, a identidade usada para receber convites, a conta conectada e os cofres
partilhados com você. Antes, sincroniza o que estiver pendente; se algo não puder subir, avisa. E recusa
apagar um cofre que nunca foi enviado para a nuvem, porque essa seria a única cópia. Para isso existe
Configurações → Avançado → “Apagar dados deste dispositivo”, que pede confirmação explícita.
</li>
<li>
<strong>No Drive:</strong> apague a pasta <code>eQuantic Keeper</code>, se você moveu o cofre para
Expand Down Expand Up @@ -265,8 +270,11 @@ <h2 style="border: 0; padding-top: 0">Privacy policy (English)</h2>
scope here can read your other files, mail, contacts or calendar.
</p>
<p>
<strong>Deleting your data.</strong> Configurações → Avançado → “Apagar deste dispositivo” clears this
browser; deleting the <code>eQuantic Keeper</code> folder removes the Drive copy; revoking the app at
<strong>Deleting your data.</strong> <em>Sair deste aparelho</em>, beside your account in the sidebar,
removes everything of yours from this browser — the vault, the stored key, the encrypted attachments,
the invite identity, the connected account and any shared vaults — after syncing whatever is pending.
It refuses to delete a vault that was never uploaded, since that would be the only copy;
Configurações → Avançado → “Apagar dados deste dispositivo” does that, after an explicit warning. Deleting the <code>eQuantic Keeper</code> folder removes the Drive copy; revoking the app at
<a href="https://myaccount.google.com/permissions">myaccount.google.com/permissions</a> ends all
access.
</p>
Expand Down
80 changes: 80 additions & 0 deletions scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1969,6 +1969,86 @@ const run = async () => {
});
await page.fill('input[type="search"]', '');

/*
* 11d. Leaving the device.
*
* Last on this page because it ends the session it runs in (the phone below
* gets a context of its own). The vault here was never uploaded, which makes
* it the case that matters most: signing out must REFUSE, because this is the
* only copy there is. Then the deliberate wipe from Advanced, which must take
* everything — asserted by reading what is on disk before and after. "The
* screen changed" is exactly the check that passed for months while the
* encrypted attachment cache and the invite identity sat there untouched.
*/
const onDevice = () =>
page.evaluate(async () => {
const existing = new Set((await indexedDB.databases()).map((db) => db.name));
const count = (name, store) =>
existing.has(name)
? new Promise((resolve) => {
const request = indexedDB.open(name);
request.onsuccess = () => {
const db = request.result;
if (!db.objectStoreNames.contains(store)) {
db.close();
return resolve(0);
}
const counted = db.transaction(store, 'readonly').objectStore(store).count();
counted.onsuccess = () => {
db.close();
resolve(counted.result);
};
counted.onerror = () => resolve(-1);
};
request.onerror = () => resolve(-1);
})
: Promise.resolve(0);
return {
cache: localStorage.getItem('keeper.vault.cache.v1') !== null,
recentTypes: localStorage.getItem('keeper.recentTypes.v1') !== null,
attachments: await count('keeper-attachments', 'ciphertext'),
identity: await count('keeper-identity', 'identity'),
keystore: await count('keeper-keystore', 'derived'),
};
});

const before = await onDevice();
console.log(` (antes de sair: ${JSON.stringify(before)})`);
// Without this, every "empty afterwards" below would pass on a device that
// never had anything to begin with.
await check('antes de sair há mesmo coisas suas neste aparelho', async () =>
before.cache && before.recentTypes && before.attachments > 0 && before.identity > 0);

let refusal = { type: '', message: '' };
page.once('dialog', (dialog) => {
refusal = { type: dialog.type(), message: dialog.message() };
return dialog.accept();
});
await page.locator('[data-sidebar-footer] [data-sign-out]').click();
await page.waitForTimeout(800);
await check('sair recusa apagar um cofre que só existe aqui', async () =>
refusal.type === 'alert' && refusal.message.includes('nunca foi enviado'));
await check('e o cofre continua inteiro depois da recusa', async () =>
(await onDevice()).cache && (await page.locator('main li').count()) > 0);

await openSettings(page, 'Avançado');
let warning = '';
page.once('dialog', (dialog) => {
warning = dialog.message();
return dialog.accept();
});
await page.click('[role="dialog"] button:has-text("Apagar dados deste dispositivo")');
await page.waitForSelector('text=Continuar com o Google', { timeout: 10000 });
await check('apagar diz que um cofre sem cópia se vai para sempre', async () =>
warning.includes('para sempre') && !warning.includes('continua no'));

const after = await onDevice();
console.log(` (depois de apagar: ${JSON.stringify(after)})`);
await check('apagar leva o cofre e o rastro do que foi criado', async () => !after.cache && !after.recentTypes);
await check('apagar leva os anexos cifrados guardados no aparelho', async () => after.attachments === 0);
await check('apagar leva a identidade de convites deste aparelho', async () => after.identity === 0);
await check('apagar leva a chave guardada', async () => after.keystore === 0);

// 12. Mobile: the same vault on a phone-sized, touch-first viewport. A fresh
// page (fresh context) so the flow is seeded from scratch at 375px.
const phone = await browser.newPage({
Expand Down
14 changes: 5 additions & 9 deletions src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useKeeper } from '../state/keeper';
import { exportBundle, exportEncrypted, exportPlaintext } from '../lib/backup';
import { estimateStrength } from '../lib/generator';
import { createPerson, getType, type CustomTypeDef, type Person } from '../lib/model';
import { signOutFlow } from './sign-out-flow';
import { getClientId, getMsClientId, getPickerApiKey, setMsClientId, setPickerApiKey } from '../lib/storage';
import { TOMBSTONE_TTL_DAYS, activeCustomTypes, activePeople } from '../lib/vault';
import { KEEPER_FOLDER_NAME, type DrivePermission, type DriveUsage } from '../lib/drive';
Expand Down Expand Up @@ -1134,15 +1135,10 @@ function AdvancedPane({ onClose }: { onClose: () => void }) {
size="sm"
variant="danger"
icon="trash"
onClick={() => {
if (
confirm(
`Apagar o cofre salvo neste navegador? A cópia no ${service} permanece intacta e pode ser baixada de novo.`,
)
) {
actions.wipeDevice();
onClose();
}
onClick={async () => {
// The old confirmation promised "the copy in the cloud stays
// intact" to people whose vault had never been uploaded.
if (await signOutFlow(actions, 'wipe', service)) onClose();
}}
>
Apagar dados deste dispositivo
Expand Down
25 changes: 25 additions & 0 deletions src/components/sign-out-flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { signOutPrompt, type SignOutMode } from '../lib/sign-out';
import type { KeeperActions } from '../state/keeper';

/**
* Asks, then leaves — the one path every door goes through, so the words a
* person reads before losing something cannot drift between the sidebar, the
* unlock screen and Advanced.
*
* Resolves true when the device was actually signed out, so a caller that
* lives inside a dialog knows whether to close it.
*/
export async function signOutFlow(
actions: Pick<KeeperActions, 'signOutRisk' | 'signOutOfDevice'>,
mode: SignOutMode,
service: string,
): Promise<boolean> {
const prompt = signOutPrompt(await actions.signOutRisk(), mode, service);
if (prompt.kind === 'refuse') {
window.alert(prompt.message);
return false;
}
if (!window.confirm(prompt.message)) return false;
await actions.signOutOfDevice();
return true;
}
57 changes: 57 additions & 0 deletions src/lib/sign-out.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { assessSignOut, signOutPrompt } from './sign-out';

describe('sair deste aparelho', () => {
it('é seguro quando a nuvem já tem tudo', () => {
expect(assessSignOut({ hasVault: true, hasRemote: true, pending: false })).toEqual({ kind: 'none' });
});

it('recusa quando o cofre nunca saiu daqui, mesmo sem nada pendente', () => {
// "Somente local": não há outra cópia em lado nenhum. Não é um aviso, é um não.
expect(assessSignOut({ hasVault: true, hasRemote: false, pending: false }).kind).toBe('only-copy');
expect(assessSignOut({ hasVault: true, hasRemote: false, pending: true }).kind).toBe('only-copy');
});

it('avisa das alterações que ainda não subiram, e diz porquê', () => {
expect(
assessSignOut({ hasVault: true, hasRemote: true, pending: true, reason: 'Sem conexão com o OneDrive.' }),
).toEqual({ kind: 'unsynced', reason: 'Sem conexão com o OneDrive.' });
});

it('um convidado sem cofre próprio não tem nada a perder aqui', () => {
expect(assessSignOut({ hasVault: false, hasRemote: false, pending: true })).toEqual({ kind: 'none' });
});
});

describe('o que a pessoa lê antes de apagar', () => {
it('sair recusa apagar a única cópia, e diz como resolver', () => {
const prompt = signOutPrompt({ kind: 'only-copy' }, 'sign-out', 'Google Drive');
expect(prompt.kind).toBe('refuse');
expect(prompt.message).toMatch(/nunca foi enviado/);
expect(prompt.message).toMatch(/exporte um backup/);
// Serve também na tela de desbloqueio, onde as Configurações só abrem com o cofre aberto.
expect(prompt.message).toMatch(/abra o cofre/);
});

it('apagar deixa destruir a única cópia, mas não esconde que é para sempre', () => {
const prompt = signOutPrompt({ kind: 'only-copy' }, 'wipe', 'Google Drive');
expect(prompt.kind).toBe('confirm');
expect(prompt.message).toMatch(/para sempre/);
// A confirmação antiga prometia exatamente isto a quem não tinha cópia nenhuma.
expect(prompt.message).not.toMatch(/continua no/);
});

it('com alterações por subir, diz que se perdem e porquê', () => {
const prompt = signOutPrompt({ kind: 'unsynced', reason: 'Sem internet neste momento.' }, 'sign-out', 'OneDrive');
expect(prompt.message).toMatch(/ainda não chegaram ao OneDrive/);
expect(prompt.message).toMatch(/Sem internet neste momento\./);
expect(prompt.message).toMatch(/se perdem/);
});

it('quando é seguro, diz onde o cofre continua e como voltar', () => {
const prompt = signOutPrompt({ kind: 'none' }, 'sign-out', 'OneDrive');
expect(prompt).toMatchObject({ kind: 'confirm' });
expect(prompt.message).toMatch(/continua no OneDrive/);
expect(prompt.message).toMatch(/senha mestra/);
});
});
93 changes: 93 additions & 0 deletions src/lib/sign-out.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Whether leaving this device can cost the person anything.
*
* Signing out removes everything of theirs from this browser, which is only
* safe when the cloud already holds all of it. Three situations, and telling
* the last two apart is the whole reason this exists:
*
* - nothing here that the cloud lacks: leave;
* - edits made here that have not gone up yet: leaving drops them, and the
* person has to know that before they press the button, not after;
* - a vault that never left this device at all: leaving destroys the only
* copy there is. Refused rather than warned about — signing out must never
* be the button that deletes someone's vault.
*/
export type SignOutRisk =
| { kind: 'none' }
| { kind: 'unsynced'; reason: string }
| { kind: 'only-copy' };

export interface SignOutFacts {
/** This device holds a vault of the person's own, open or cached. */
hasVault: boolean;
/** It has been uploaded at least once, so the cloud has some version of it. */
hasRemote: boolean;
/** Edits exist that the cloud has not received. */
pending: boolean;
/** Why they have not gone up, when that is known. */
reason?: string;
}

export function assessSignOut(facts: SignOutFacts): SignOutRisk {
// A guest looking at someone else's vault has nothing of their own here: the
// vault they were reading lives in the owner's cloud, and stays there.
if (!facts.hasVault) return { kind: 'none' };
if (!facts.hasRemote) return { kind: 'only-copy' };
if (facts.pending) {
return { kind: 'unsynced', reason: facts.reason ?? 'As alterações ainda não chegaram à nuvem.' };
}
return { kind: 'none' };
}

/**
* Which button was pressed. Signing out is the everyday action and refuses to
* destroy the only copy; wiping is the deliberate one in Advanced, and lets a
* person throw away a vault that only ever lived here — but only after saying
* in so many words that it is gone for good.
*/
export type SignOutMode = 'sign-out' | 'wipe';

export type SignOutPrompt = { kind: 'refuse'; message: string } | { kind: 'confirm'; message: string };

/**
* The words the person reads before anything is deleted.
*
* Kept here rather than in a component because what they say is the safety
* mechanism: the old confirmation in Advanced promised "the copy in the cloud
* stays intact" to someone whose vault had never been uploaded.
*/
export function signOutPrompt(risk: SignOutRisk, mode: SignOutMode, service: string): SignOutPrompt {
if (risk.kind === 'only-copy') {
if (mode === 'sign-out') {
return {
kind: 'refuse',
message:
`Este cofre só existe neste aparelho: ele nunca foi enviado para o ${service}. ` +
'Sair agora apagaria o cofre para sempre.\n\n' +
'Antes de sair, abra o cofre e exporte um backup (Configurações → Backup), ou conecte uma conta ' +
'para ele subir.',
};
}
return {
kind: 'confirm',
message:
'Este cofre só existe neste aparelho. Apagar agora destrói o cofre para sempre: ' +
`não há cópia no ${service} nem em lugar nenhum.\n\nApagar mesmo assim?`,
};
}
if (risk.kind === 'unsynced') {
return {
kind: 'confirm',
message:
`Há alterações feitas neste aparelho que ainda não chegaram ao ${service}. ${risk.reason}\n\n` +
'Se continuar, elas se perdem. Continuar mesmo assim?',
};
}
return {
kind: 'confirm',
message:
(mode === 'sign-out' ? 'Sair deste aparelho?' : 'Apagar tudo deste aparelho?') +
`\n\nTudo o que é seu sai deste navegador. O cofre continua no ${service}: ` +
'para voltar, entre de novo com a conta e a senha mestra.',
};
}
33 changes: 32 additions & 1 deletion src/lib/storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getProvider, setProvider } from './storage';
import { getProvider, isSyncPending, markSyncPending, setProvider, wipeLocalData } from './storage';

/**
* A localStorage, because the suite runs under node and there is none.
Expand Down Expand Up @@ -71,3 +71,34 @@ describe('qual serviço este dispositivo usa', () => {
expect(() => setProvider('microsoft')).not.toThrow();
});
});

describe('alterações que ainda não subiram', () => {
it('sobrevivem a um refresh, porque ficam gravadas', () => {
markSyncPending(true);
expect(isSyncPending()).toBe(true);
markSyncPending(false);
expect(isSyncPending()).toBe(false);
});
});

describe('o que sai do aparelho, e o que fica', () => {
it('leva tudo o que é da pessoa, e deixa configuração e aparência', () => {
const store = stubStorage({
// Da pessoa.
'keeper.vault.cache.v1': '{}',
'keeper.google.account': '{}',
'keeper.biometric.v1': '{}',
'keeper.drive.folder.v1': 'pasta',
'keeper.shared.v1': '[]',
'keeper.recentTypes.v1': '["passport"]',
'keeper.sync.pending': '1',
// Do aparelho.
'keeper.google.clientId': 'fork.apps.googleusercontent.com',
'keeper.theme': 'light',
});

wipeLocalData();

expect([...store.keys()].sort()).toEqual(['keeper.google.clientId', 'keeper.theme']);
});
});
Loading
Loading