fix: ask to clear a desktop app lock this profile has no PIN for

This commit is contained in:
Linus Rath
2026-11-04 02:57:04 +01:00
parent dc8482ace0
commit 7f50113b90
3 changed files with 43 additions and 0 deletions
+27
View File
@@ -84,6 +84,7 @@ function bridge(overrides: Record<string, unknown> = {}) {
prove: vi.fn(async () => true),
disable: vi.fn(async () => true),
forget: vi.fn(async () => true),
clearOrphan: vi.fn(async () => true),
osEnroll: vi.fn(async () => true),
osRemove: vi.fn(async () => {}),
osUnlock: vi.fn(async () => ({ ok: false as const, reason: 'denied' as const })),
@@ -439,6 +440,32 @@ describe('the store when a tab inherits it without the key', () => {
});
describe('the desktop app', () => {
const settled = () => new Promise((resolve) => setTimeout(resolve, 50));
it('asks main to clear a lock this profile has no PIN for', async () => {
const appLock = bridge({ status: vi.fn(async () => ({ locked: true, enrolled: true, os: null, osEnrolled: false })) });
bootAppLock();
await settled();
expect(appLock.clearOrphan).toHaveBeenCalledTimes(1);
expect(appLock.forget).not.toHaveBeenCalled();
});
it('leaves main alone when there is a vault, so a real lock is never questioned', async () => {
const raw = await createVault(PIN);
raw.fill(0);
const appLock = bridge({ status: vi.fn(async () => ({ locked: true, enrolled: true, os: null, osEnrolled: false })) });
bootAppLock();
await settled();
expect(appLock.clearOrphan).not.toHaveBeenCalled();
});
it('asks nothing when main has no lock', async () => {
const appLock = bridge({ status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false })) });
bootAppLock();
await settled();
expect(appLock.clearOrphan).not.toHaveBeenCalled();
});
it('a PIN unlock proves itself to main, and nothing can just say unlocked', async () => {
const appLock = bridge();
await enableAppLock(PIN);
+14
View File
@@ -191,6 +191,7 @@ export function bootAppLock(): void {
return;
}
markDocument(false);
void clearOrphanedDesktopLock();
// The look already came back empty: nothing to hold back for.
if (headScriptFoundNoVault() && !setupNeeded()) return;
hold = newHold();
@@ -203,6 +204,19 @@ export function bootAppLock(): void {
void settleNoFile();
}
/**
* Main keeps a lock and there is no vault for it: a same-origin plugin frame enrolled one, or
* this profile's storage went. Nothing here could ever prove it, so tray, toasts and titles would
* stay blank for good. No page can lift main's lock on its word, so main asks the user.
*/
async function clearOrphanedDesktopLock(): Promise<void> {
const member = getDesktopBridge()?.appLock;
if (!member?.clearOrphan) return;
const status = await member.status().catch(() => null);
if (!status?.enrolled || await vaultExists() || enabling || readAppLockFile().on) return;
await member.clearOrphan().catch(() => false);
}
function lockedFromNow(): void {
writeAppLockFile({ ...readAppLockFile(), on: true, locked: true });
setLockedHere(true);
+2
View File
@@ -367,6 +367,8 @@ export interface DesktopAppLockBridge {
disable: (proof: Uint8Array) => Promise<boolean>;
/** The PIN is lost, or everyone signed out. With no proof, main asks the user natively. */
forget: (proof?: Uint8Array) => Promise<boolean>;
/** Main keeps a lock and this profile has no PIN for it. Main asks the user natively, once a run. */
clearOrphan?: () => Promise<boolean>;
/** Through safeStorage, for this origin only. Null where the OS has no real key store. */
wrap: (data: Uint8Array) => Promise<Uint8Array | null>;
unwrap: (data: Uint8Array) => Promise<Uint8Array | null>;