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
39 changes: 39 additions & 0 deletions scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1926,6 +1926,45 @@ const run = async () => {
await page.click('button:has-text("Desbloquear")');
await page.waitForSelector('text=GitHub PAT', { timeout: 20000 });

/*
* 11b2. A lock that lands in the middle of a password change.
*
* The change derives a key twice (PBKDF2, most of a second each) and only
* then writes: the new key into memory, and into the keystore. A lock pressed
* during those seconds used to be undone when the change resumed — and the
* keystore write also cleared the lock's revocation mark, so the next reload
* opened the vault with no password at all.
*
* Not a race: the click and the lock run in the same synchronous piece of
* script, so the lock always lands while the first derivation is pending.
*/
const MID_LOCK_PASSWORD = `${PASSWORD}-durante-o-bloqueio`;
await openSettings(page, 'Segurança');
await page.click('[role="dialog"] button:has-text("Alterar senha mestra")');
await page.locator('[role="dialog"] label:has-text("Senha mestra atual") input').first().fill(PASSWORD);
await page.locator('[role="dialog"] label:has-text("Nova senha mestra") input').first().fill(MID_LOCK_PASSWORD);
await page.locator('[role="dialog"] label:has-text("Confirme a nova senha") input').first().fill(MID_LOCK_PASSWORD);
await page.evaluate(() => {
const submit = [...document.querySelectorAll('[role="dialog"] button')].find(
(button) => button.textContent?.trim() === 'Alterar senha',
);
submit.click();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'l', ctrlKey: true, bubbles: true }));
});
await page.waitForSelector('text=Desbloquear cofre', { timeout: 10000 });
// Long enough for both derivations and the seal to finish, had it gone on.
await page.waitForTimeout(4000);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2500);
await check('um bloqueio no meio da troca de senha não é desfeito', async () =>
(await page.locator('text=Desbloquear cofre').count()) === 1 &&
(await page.locator('text=GitHub PAT').count()) === 0);
// Abandoned, not half-done: the old password still opens it.
await page.fill('input[type="password"]', PASSWORD);
await page.click('button:has-text("Desbloquear")');
await page.waitForSelector('text=GitHub PAT', { timeout: 30000 });
await check('e a senha continua a antiga, sem troca pela metade', async () => true);

/*
* 11c. A passkey, created through the real form.
*
Expand Down
111 changes: 98 additions & 13 deletions src/state/keeper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,21 @@ export function KeeperProvider({ children }: { children: ReactNode }) {
[patch],
);

/**
* Whether the vault session `keys` came from is still the open one.
*
* The keys object IS the session: a lock drops it, and every unlock mints a
* new one. Anything that awaits and then writes session state asks this
* first. Without it, a lock that landed during the await is quietly undone
* when the work resumes: the decrypted vault goes back into memory behind the
* lock screen — and a password change even wrote its new key to disk, so the
* next reload opened the vault with no password at all.
*
* Writes that describe what is now in the cloud (ids, revisions, ciphertext)
* are not session state and still land; only the secrets stay out.
*/
const stillOpen = useCallback((keys: VaultKeys) => keysRef.current === keys, []);

const setPayload = useCallback(
(payload: VaultPayload | null) => {
payloadRef.current = payload;
Expand Down Expand Up @@ -594,7 +609,10 @@ export function KeeperProvider({ children }: { children: ReactNode }) {
// first, so the vault we write already points at them — the reverse
// order would publish a reference to a file that does not exist.
const pushed = await pushPendingAttachments(drive, payload);
if (pushed !== payload) setPayload(pushed);
// Uploads can take many seconds on a phone, long enough for the
// auto-lock to fire. The push still finishes below, from `pushed`;
// what must not happen is the decrypted vault going back into memory.
if (pushed !== payload && stillOpen(keys)) setPayload(pushed);

const result = await syncVault(
{
Expand All @@ -610,7 +628,7 @@ export function KeeperProvider({ children }: { children: ReactNode }) {
driveIdRef.current = result.driveFileId;
revisionRef.current = result.revision;
fileRef.current = result.file;
if (result.merged) setPayload(result.payload);
if (result.merged && stillOpen(keys)) setPayload(result.payload);
storage.saveCachedVault({
file: result.file,
driveFileId: result.driveFileId,
Expand Down Expand Up @@ -646,7 +664,7 @@ export function KeeperProvider({ children }: { children: ReactNode }) {
if (!options.silent && !expired) patch({ error: message });
}
},
[patch, pushPendingAttachments, setPayload],
[patch, pushPendingAttachments, setPayload, stillOpen],
);

const scheduleSync = useCallback(() => {
Expand Down Expand Up @@ -872,6 +890,10 @@ export function KeeperProvider({ children }: { children: ReactNode }) {
driveFileId: driveIdRef.current,
knownRevision: revisionRef.current,
});
// Locked while the download was in flight: drop the result whole, ids
// and revision included. Keeping those while skipping the merge would
// tell the next unlock it already holds a revision it never cached.
if (!stillOpen(keys)) return;
if (remote) {
driveIdRef.current = remote.driveFileId;
revisionRef.current = remote.revision;
Expand All @@ -887,7 +909,7 @@ export function KeeperProvider({ children }: { children: ReactNode }) {
patch({ sync: { status: 'error', message } });
}
},
[patch, persistLocal, scheduleSync, setPayload],
[patch, persistLocal, scheduleSync, setPayload, stillOpen],
);

const unlock = useCallback(
Expand Down Expand Up @@ -1002,6 +1024,10 @@ export function KeeperProvider({ children }: { children: ReactNode }) {
window.clearTimeout(syncTimerRef.current);
ownSessionRef.current = null;
derivedRef.current = null;
// The data key too, not only the password's. It is the one that opens the
// payload and every attachment, and it used to stay in memory until the
// next unlock replaced it — usable by anything still running.
keysRef.current = null;
payloadRef.current = null;
// Nothing of a guest session survives a lock: there is no local copy of
// someone else's vault to come back to, by design.
Expand Down Expand Up @@ -1327,7 +1353,10 @@ export function KeeperProvider({ children }: { children: ReactNode }) {
async (current: string, next: string) => {
const file = fileRef.current;
const payload = payloadRef.current;
if (!file || !payload) throw new Error('O cofre precisa estar aberto.');
// Taken now, before the first await, and never re-read: the change
// belongs to the session it started in.
const session = keysRef.current;
if (!file || !payload || !session) throw new Error('O cofre precisa estar aberto.');
patch({ busy: true, error: null });
try {
const check = await deriveKey(current, file.kdf);
Expand All @@ -1336,11 +1365,18 @@ export function KeeperProvider({ children }: { children: ReactNode }) {
// The content key does not change: only its wrapping does. That is why
// attachments survive a password change now — their keys hang off the
// data key, which is exactly the same key before and after.
const data = keysRef.current?.data;
if (!data) throw new Error('O cofre está bloqueado.');
const data = session.data;
const derived = await deriveKey(next, newKdfParams());
const keys: VaultKeys = { derived, data };
const rebuilt = await sealVault(keys, payload);
// Two derivations and a seal lie behind this line — seconds, not
// milliseconds. Every write below is session state, and one of them
// puts a key on disk. A lock that landed in those seconds must stand:
// the change is abandoned whole rather than finished into a locked
// vault. Nothing has been written yet, so nothing is left half-done.
if (!stillOpen(session)) {
throw new Error('O cofre foi bloqueado durante a troca. A senha mestra não foi alterada.');
}
derivedRef.current = derived;
keysRef.current = keys;
fileRef.current = rebuilt;
Expand All @@ -1367,7 +1403,7 @@ export function KeeperProvider({ children }: { children: ReactNode }) {
throw error;
}
},
[fail, patch, refreshBiometric, runSync],
[fail, patch, refreshBiometric, runSync, stillOpen],
);

const importBackup = useCallback(
Expand Down Expand Up @@ -1643,12 +1679,61 @@ export function KeeperProvider({ children }: { children: ReactNode }) {
await writeShares(drive, stored, await Promise.all(keep.map((record) => rewrapShare(record, next.data))));

const rotated = { ...payload, items };
keysRef.current = next;
setPayload(rotated);
await persistLocal(rotated);
await runSync({ force: true });
if (stillOpen(keys)) {
keysRef.current = next;
setPayload(rotated);
await persistLocal(rotated);
await runSync({ force: true });
return;
}

/*
* Locked somewhere in the rotation. Stopping is not an option once the
* shares above hold the new key: the vault must follow, or everyone
* staying is locked out while future writes keep a key the person just
* removed still holds. So it finishes from the values in hand — sealed
* and saved straight to the cache, then forced to the cloud — without
* touching a single ref: nothing decrypted goes back into memory. The
* next unlock opens the rotated vault, since the cache wraps the new key
* under the same password.
*/
const sealed = await sealVault(next, rotated);
fileRef.current = sealed;
storage.saveCachedVault({
file: sealed,
...(driveIdRef.current ? { driveFileId: driveIdRef.current } : {}),
...(revisionRef.current ? { driveRevision: revisionRef.current } : {}),
cachedAt: new Date().toISOString(),
});
try {
const result = await syncVault(
{
drive,
derived: next.derived,
keys: next,
driveFileId: driveIdRef.current,
knownRevision: revisionRef.current,
},
rotated,
{ force: true },
);
driveIdRef.current = result.driveFileId;
revisionRef.current = result.revision;
fileRef.current = result.file;
storage.saveCachedVault({
file: result.file,
driveFileId: result.driveFileId,
...(result.revision ? { driveRevision: result.revision } : {}),
cachedAt: new Date().toISOString(),
});
setPending(false);
} catch {
// Offline, or the session ran out: the cache already holds the rotated
// vault, and the first sync after the next unlock carries it up.
setPending(true);
}
},
[persistLocal, runSync, setPayload],
[persistLocal, runSync, setPayload, setPending, stillOpen],
);

const revokeShare = useCallback(
Expand Down
Loading