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
54 changes: 54 additions & 0 deletions src-tauri/src/app_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ impl AppLock {
}
}

pub struct LastActive {
path: PathBuf,
}

impl LastActive {
pub fn load() -> Self {
Self {
path: crate::storage::config::config_dir().join("last-active"),
}
}

pub fn get(&self) -> Option<u64> {
std::fs::read_to_string(&self.path)
.ok()?
.trim()
.parse()
.ok()
}

pub fn set(&self, at_ms: u64) -> std::io::Result<()> {
std::fs::write(&self.path, at_ms.to_string())
}
}

pub fn is_locked(app: &tauri::AppHandle) -> bool {
app.try_state::<AppLock>()
.is_some_and(|l| l.get().is_some())
Expand Down Expand Up @@ -97,6 +121,17 @@ pub fn app_lock_set(lock: tauri::State<'_, AppLock>, kind: Option<String>) -> Re
persisted.map_err(|e| format!("could not persist the app lock: {e}"))
}

#[tauri::command]
pub fn app_lock_last_active(last: tauri::State<'_, LastActive>) -> Option<u64> {
last.get()
}

#[tauri::command]
pub fn app_lock_touch(last: tauri::State<'_, LastActive>, at: u64) -> Result<(), String> {
last.set(at)
.map_err(|e| format!("could not persist the last activity: {e}"))
}

#[tauri::command]
pub fn app_lock_hide_in_recents(on: bool) {
#[cfg(target_os = "android")]
Expand All @@ -109,6 +144,25 @@ pub fn app_lock_hide_in_recents(on: bool) {
mod tests {
use super::*;

#[test]
fn last_activity_survives_a_reload_from_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("last-active");
assert_eq!(LastActive { path: path.clone() }.get(), None);
LastActive { path: path.clone() }
.set(1_700_000_000_000)
.unwrap();
assert_eq!(LastActive { path }.get(), Some(1_700_000_000_000));
}

#[test]
fn unreadable_last_activity_reads_as_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("last-active");
std::fs::write(&path, "garbage").unwrap();
assert_eq!(LastActive { path }.get(), None);
}

#[test]
fn a_fresh_store_is_unlocked() {
let dir = tempfile::tempdir().unwrap();
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ pub fn run() {
app.manage(updater::UpdaterState::default());
app.manage(KnownHostsStore::load());
app.manage(app_lock::AppLock::load());
app.manage(app_lock::LastActive::load());
app.manage(Arc::new(PendingConflicts::new()));
app.manage(PortForwardManager::new(app.handle().clone()));
app.manage(Arc::new(mcp::McpState::new()));
Expand Down Expand Up @@ -277,6 +278,8 @@ pub fn run() {
app_lock::app_lock_get,
app_lock::app_lock_set,
app_lock::app_lock_hide_in_recents,
app_lock::app_lock_last_active,
app_lock::app_lock_touch,
system_auth::system_auth_available,
system_auth::system_auth_verify,
terminal_kbd::terminal_show_keyboard,
Expand Down
26 changes: 22 additions & 4 deletions src-tauri/src/storage/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,26 @@ impl SecretsStore {
}

pub fn unlock(&self, path: PathBuf, enc_key: [u8; 32]) -> Result<(), AppError> {
let data = if path.exists() {
let existed = path.exists();
let data = if existed {
let bytes = std::fs::read(&path).map_err(read_failed)?;
decrypt(&enc_key, &bytes)?
} else {
SecretsData::default()
};
*self.inner.lock().unwrap() = Some(StoreInner {
let inner = StoreInner {
enc_key,
secrets: data.secrets,
clocks: data.clocks,
path,
});
};
// Without a file, the next unlock has nothing to check a password against.
if !existed {
if let Err(e) = save(&inner) {
log::warn!("could not write the empty vault: {e}");
}
}
*self.inner.lock().unwrap() = Some(inner);
Ok(())
}

Expand Down Expand Up @@ -1113,6 +1121,15 @@ mod tests {
(store, path)
}

#[test]
fn unlocking_without_a_file_writes_one_only_that_key_opens() {
let dir = tempfile::tempdir().unwrap();
let (_, path) = unlocked_store(&dir);
assert!(path.exists());
assert!(SecretsStore::new().unlock(path.clone(), [8u8; 32]).is_err());
assert!(SecretsStore::new().unlock(path, [7u8; 32]).is_ok());
}

#[test]
fn purge_removes_only_present_keys_and_tombstones_them() {
let dir = tempfile::tempdir().unwrap();
Expand All @@ -1136,8 +1153,9 @@ mod tests {
fn purge_with_nothing_present_does_not_write() {
let dir = tempfile::tempdir().unwrap();
let (store, path) = unlocked_store(&dir);
let before = std::fs::read(&path).unwrap();
assert!(store.purge(&["password:x".to_string()]).unwrap().is_empty());
assert!(!path.exists());
assert_eq!(std::fs::read(&path).unwrap(), before);
}

#[test]
Expand Down
43 changes: 37 additions & 6 deletions src/components/layout/SplashScreen.appLock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const h = vi.hoisted(() => ({
systemAuthUnlock: false,
available: true,
exists: true,
serverMode: false,
mode: null as string | null,
launchLock: null as string | null,
secret: "plain",
authProps: null as null | { isLocked: boolean; systemAuth?: boolean },
}));
Expand All @@ -21,9 +22,13 @@ vi.mock("@/services/account", () => ({
autoLogin: h.autoLogin,
getAppLock: vi.fn(async () => h.lock),
setAppLock: vi.fn(async () => {}),
isServerMode: vi.fn(async () => h.serverMode),
isServerMode: vi.fn(async () => h.mode === "server"),
getAccountMode: vi.fn(async () => h.mode),
}));
vi.mock("@/services/appLock", () => ({
systemAuthAvailable: vi.fn(async () => h.available),
lockOnLaunchIfIdle: vi.fn(async () => h.launchLock),
}));
vi.mock("@/services/appLock", () => ({ systemAuthAvailable: vi.fn(async () => h.available) }));
vi.mock("@/services/vaultSecret", () => ({ secretState: vi.fn(async () => h.secret) }));
vi.mock("@/stores/securityStore", () => ({
useSecurityStore: { getState: () => ({ systemAuthUnlock: h.systemAuthUnlock }) },
Expand All @@ -41,7 +46,8 @@ beforeEach(() => {
h.lock = null;
h.systemAuthUnlock = false;
h.exists = true;
h.serverMode = false;
h.mode = null;
h.launchLock = null;
h.secret = "plain";
h.authProps = null;
h.autoLogin.mockClear();
Expand Down Expand Up @@ -85,7 +91,7 @@ test("a screen lock that survived a restart is up before the shell shows", async
test("a vault-locked cloud account with no vault file yet still gets the unlock page", async () => {
h.lock = "vault";
h.exists = false;
h.serverMode = true;
h.mode = "server";
render(<SplashScreen onReady={() => {}} />);
await advance(2000);
expect(h.authProps).toMatchObject({ isLocked: true });
Expand All @@ -99,6 +105,31 @@ test("a vault lock with no vault file and no cloud account is a first launch", a
expect(h.authProps).toMatchObject({ isLocked: false });
});

test("a vault-locked local account that never wrote a secret gets the unlock page, not onboarding", async () => {
h.lock = "vault";
h.exists = false;
h.mode = "local";
render(<SplashScreen onReady={() => {}} />);
await advance(2000);
expect(h.authProps).toMatchObject({ isLocked: true });
});

test("a launch past the auto-lock timeout with Lock vault goes to the unlock page, never auto-login", async () => {
h.launchLock = "vault";
render(<SplashScreen onReady={() => {}} />);
await advance(2000);
expect(h.autoLogin).not.toHaveBeenCalled();
expect(h.authProps).toMatchObject({ isLocked: true });
});

test("a launch past the auto-lock timeout with Lock screen still opens the vault behind the lock", async () => {
h.launchLock = "screen";
render(<SplashScreen onReady={() => {}} />);
await advance(10_000);
expect(h.autoLogin).toHaveBeenCalled();
expect(h.authProps).toBeNull();
});

test("sealed launch goes to the locked screen without a vault file", async () => {
h.autoLogin.mockResolvedValueOnce("sealed");
h.exists = false;
Expand All @@ -118,7 +149,7 @@ test("a vault lock over a sealed secret offers system auth even with the setting
test("a declined login on a cloud account with no vault file yet still gets the unlock page", async () => {
h.autoLogin.mockResolvedValueOnce("declined");
h.exists = false;
h.serverMode = true;
h.mode = "server";
render(<SplashScreen onReady={() => {}} />);
await advance(2000);
expect(h.authProps).toMatchObject({ isLocked: true });
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/SplashScreen.keychainHint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ vi.mock("@/services/account", () => ({
getAppLock: vi.fn(async () => null),
setAppLock: vi.fn(async () => {}),
isServerMode: vi.fn(async () => false),
getAccountMode: vi.fn(async () => "local"),
}));
vi.mock("@/services/appLock", () => ({ systemAuthAvailable: async () => false, lockOnLaunchIfIdle: async () => null }));
vi.mock("@/services/vault", () => ({ getVaultStatus: vi.fn(async () => ({ exists: true })) }));
vi.mock("./AuthPage", () => ({ default: () => <div>auth-page</div> }));
vi.mock("./LogoBadge", () => ({ default: () => <div /> }));
Expand Down
11 changes: 6 additions & 5 deletions src/components/layout/SplashScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { useFolderStore } from "@/stores/folderStore";
import { useSnippetStore } from "@/stores/snippetStore";
import { useSnippetFolderStore } from "@/stores/snippetFolderStore";
import { usePortForwardingStore } from "@/stores/portForwardingStore";
import { autoLogin, getAppLock, isServerMode, setAppLock } from "@/services/account";
import { systemAuthAvailable } from "@/services/appLock";
import { autoLogin, getAccountMode, getAppLock, isServerMode, setAppLock } from "@/services/account";
import { lockOnLaunchIfIdle, systemAuthAvailable } from "@/services/appLock";
import { secretState } from "@/services/vaultSecret";
import { useAppLockStore } from "@/stores/appLockStore";
import { useSecurityStore } from "@/stores/securityStore";
Expand All @@ -22,6 +22,7 @@ import { usePluginRegistryStore } from "@/stores/pluginRegistryStore";
import { useThemeStore } from "@/stores/themeStore";
import { useSubscriptionStore } from "@/stores/subscriptionStore";
import { usePlatform } from "@/utils/platform";
import { canLockVault } from "@/utils/accountMode";
import AuthPage from "./AuthPage";
import LogoBadge from "./LogoBadge";

Expand All @@ -43,9 +44,9 @@ function keepSwitcherFresh(): void {
saveCurrentAccount().catch((e) => console.warn("[splash] could not save this account to the switcher:", e));
}

// A cloud account has nothing on disk until its first secret; login() re-derives from the server.
// An account can predate its vault file: a cloud one re-derives from the server, a local one never wrote a secret.
async function hasAccountToUnlock(): Promise<boolean> {
return (await getVaultStatus()).exists || (await isServerMode());
return (await getVaultStatus()).exists || canLockVault(await getAccountMode());
}

export default function SplashScreen({ onReady }: Props) {
Expand All @@ -70,7 +71,7 @@ export default function SplashScreen({ onReady }: Props) {
setStep("vault", "running");
await delay(200);

if ((await getAppLock()) === "vault") {
if (((await getAppLock()) ?? (await lockOnLaunchIfIdle().catch(() => null))) === "vault") {
const sealed = (await secretState().catch(() => "none")) === "sealed";
setSystemAuth(sealed || (useSecurityStore.getState().systemAuthUnlock && (await systemAuthAvailable())));
try {
Expand Down
28 changes: 27 additions & 1 deletion src/hooks/useSessionExpiration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import { renderHook, act, cleanup } from "@testing-library/react";
const h = vi.hoisted(() => ({
lockApp: vi.fn(async () => undefined),
hideInRecents: vi.fn(async (_on: boolean) => undefined),
recordLastActive: vi.fn(async (_at: number) => undefined),
suppressed: false,
mode: "local" as string | null,
resized: null as null | (() => void),
minimized: false,
}));

vi.mock("@/services/appLock", () => ({ lockApp: h.lockApp, setHideInRecents: h.hideInRecents }));
vi.mock("@/services/appLock", () => ({
lockApp: h.lockApp, setHideInRecents: h.hideInRecents, recordLastActive: h.recordLastActive,
}));
vi.mock("@/services/leaveLockSuppression", () => ({ isLeaveLockSuppressed: () => h.suppressed }));
vi.mock("@/services/account", () => ({
getAccountMode: async () => h.mode,
Expand Down Expand Up @@ -192,3 +195,26 @@ test("a policy timeout applies when the member chose Never", async () => {
await act(async () => { await vi.advanceTimersByTimeAsync(15 * 60_000 + 5_000); });
expect(h.lockApp).toHaveBeenCalled();
});

test("the last activity is written down for the next launch, once per change", async () => {
useSecurityStore.setState({ sessionTimeoutMinutes: 15 });
await mount();
expect(h.recordLastActive).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(h.recordLastActive).toHaveBeenCalledTimes(1);
await act(async () => {
window.dispatchEvent(new Event("keydown"));
await vi.advanceTimersByTimeAsync(5_000);
});
expect(h.recordLastActive).toHaveBeenCalledTimes(2);
expect(h.recordLastActive).toHaveBeenLastCalledWith(Date.now() - 5_000);
});

test("leaving the app writes down the last activity before it can be killed", async () => {
useSecurityStore.setState({ sessionTimeoutMinutes: 15 });
await mount();
await act(async () => { window.dispatchEvent(new Event("keydown")); });
const at = Date.now();
setVisibility("hidden");
expect(h.recordLastActive).toHaveBeenLastCalledWith(at);
});
17 changes: 14 additions & 3 deletions src/hooks/useSessionExpiration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { getAccountMode } from "@/services/account";
import { lockApp, setHideInRecents } from "@/services/appLock";
import { lockApp, recordLastActive, setHideInRecents } from "@/services/appLock";
import { isLeaveLockSuppressed } from "@/services/leaveLockSuppression";
import { useAppLockStore } from "@/stores/appLockStore";
import { useEffectiveLockSettings } from "@/hooks/useEffectiveLockSettings";
Expand All @@ -22,6 +22,7 @@ export function useSessionExpiration(ready = true): void {
const immediate = sessionTimeoutMinutes === IMMEDIATELY;
const timeoutMs = (immediate ? IMMEDIATE_IDLE_MINUTES : sessionTimeoutMinutes) * 60_000;
let lastActivityAt = Date.now();
let recordedAt = 0;
let lockable = false;
let disposed = false;
let lockInProgress = false;
Expand Down Expand Up @@ -54,17 +55,26 @@ export function useSessionExpiration(ready = true): void {
if (immediate && !isLeaveLockSuppressed()) lockNow();
};

const persistActivity = () => {
if (recordedAt === lastActivityAt) return;
recordedAt = lastActivityAt;
void recordLastActive(lastActivityAt);
};

const checkIdle = () => {
if (Date.now() - lastActivityAt >= timeoutMs) lockNow();
else persistActivity();
};

const recordActivity = () => {
if (!lockInProgress && !disposed) lastActivityAt = Date.now();
};

const onVisibilityChange = () => {
if (document.visibilityState === "hidden") lockOnLeave();
else checkIdle();
if (document.visibilityState === "hidden") {
persistActivity();
lockOnLeave();
} else checkIdle();
};

if (immediate) {
Expand All @@ -83,6 +93,7 @@ export function useSessionExpiration(ready = true): void {
for (const e of ACTIVITY_EVENTS) window.addEventListener(e, recordActivity, { passive: true });
window.addEventListener("focus", checkIdle);
document.addEventListener("visibilitychange", onVisibilityChange);
persistActivity();
const intervalId = window.setInterval(checkIdle, CHECK_INTERVAL_MS);
const unsubscribeLock = useAppLockStore.subscribe((s, prev) => {
if (prev.kind && !s.kind) lastActivityAt = Date.now();
Expand Down
Loading
Loading