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
2 changes: 2 additions & 0 deletions src-tauri/gen/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:theme="@style/Theme.voltius"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package com.voltius.app

import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import java.io.File
import java.io.IOException
import java.security.GeneralSecurityException

/**
* Secret storage backing Voltius' OS-keychain on Android.
Expand All @@ -16,7 +21,10 @@ import androidx.security.crypto.MasterKey
* surface to these three static String-in/String-out methods so the JNI side stays trivial.
*/
object VoltiusKeychain {
private const val TAG = "VoltiusKeychain"
private const val FILE = "voltius_secrets"
private const val KEEP_QUARANTINED = 3
private val QUARANTINED = Regex("""^${FILE}\.xml\.(\d+)\.bak$""")

/**
* Hands the Rust keyring store the JavaVM + application Context. Must be called once at
Expand All @@ -26,14 +34,49 @@ object VoltiusKeychain {
@JvmStatic
external fun nativeInit(context: Context)

private fun prefs(ctx: Context) = EncryptedSharedPreferences.create(
private fun open(ctx: Context, masterKey: MasterKey) = EncryptedSharedPreferences.create(
ctx,
FILE,
MasterKey.Builder(ctx).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)

/** A file this install's Keystore key can't decrypt is set aside and replaced by an empty store. */
@Synchronized
private fun prefs(ctx: Context): SharedPreferences {
val masterKey = MasterKey.Builder(ctx).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
return try {
open(ctx, masterKey)
} catch (e: GeneralSecurityException) {
quarantine(ctx, e)
open(ctx, masterKey)
} catch (e: IOException) {
quarantine(ctx, e)
open(ctx, masterKey)
}
}

private fun quarantine(ctx: Context, cause: Exception) {
val dir = File(ctx.applicationInfo.dataDir, "shared_prefs")
val file = File(dir, "$FILE.xml")
if (!file.exists()) throw cause
val taken = quarantinedStamps(dir)
var stamp = maxOf(System.currentTimeMillis(), (taken.maxOrNull() ?: 0L) + 1)
while (File(dir, "$FILE.xml.$stamp.bak").exists()) stamp++
val dest = File(dir, "$FILE.xml.$stamp.bak")
if (!file.renameTo(dest)) throw cause
// The file is already gone: this only drops the stale copy cached by the Context.
ctx.deleteSharedPreferences(FILE)
Log.w(TAG, "Could not decrypt $FILE, set it aside as ${dest.name}", cause)
quarantinedStamps(dir).sortedDescending().drop(KEEP_QUARANTINED).forEach {
File(dir, "$FILE.xml.$it.bak").delete()
}
}

private fun quarantinedStamps(dir: File): List<Long> =
dir.list().orEmpty().mapNotNull { QUARANTINED.find(it)?.groupValues?.get(1)?.toLongOrNull() }

/** Returns the stored value for [key], or null if absent. */
@JvmStatic
fun get(ctx: Context, key: String): String? = prefs(ctx).getString(key, null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Keystore key that decrypts voltius_secrets.xml never leaves the device. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="voltius_secrets.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="voltius_secrets.xml" />
</device-transfer>
</data-extraction-rules>
50 changes: 44 additions & 6 deletions src-tauri/src/android_ctx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

use std::sync::OnceLock;

use jni::objects::{GlobalRef, JClass, JObject};
use jni::objects::{GlobalRef, JClass, JObject, JString};
use jni::{JNIEnv, JavaVM};

struct AndroidCtx {
Expand Down Expand Up @@ -59,7 +59,7 @@ pub extern "system" fn Java_com_voltius_app_VoltiusKeychain_nativeInit<'local>(

/// Run `f` with an attached `JNIEnv` and the application `Context`. Returns `Err` (never
/// panics) if `nativeInit` hasn't run or the attach fails. On a Java exception, describes it
/// to logcat and clears it so the VM stays usable.
/// to logcat, clears it so the VM stays usable, and names it in the error.
pub fn with_env<T>(
op: &str,
f: impl FnOnce(&mut JNIEnv, &JObject) -> Result<T, jni::errors::Error>,
Expand All @@ -71,13 +71,51 @@ pub fn with_env<T>(
.vm
.attach_current_thread()
.map_err(|e| format!("{op}: attach: {e}"))?;
f(&mut env, ctx.context.as_obj()).map_err(|e| {
let _ = env.exception_describe();
let _ = env.exception_clear();
format!("{op}: {e}")
let result = f(&mut env, ctx.context.as_obj());
result.map_err(|e| match take_java_exception(&mut env) {
Some(thrown) => format!("{op}: {thrown}"),
None => format!("{op}: {e}"),
})
}

/// Clears the pending Java exception and renders it with its causes.
fn take_java_exception(env: &mut JNIEnv) -> Option<String> {
if !env.exception_check().unwrap_or(false) {
return None;
}
let thrown = env.exception_occurred().ok();
let _ = env.exception_describe();
let _ = env.exception_clear();
let mut current = JObject::from(thrown?);
let mut parts = Vec::new();
for _ in 0..4 {
match throwable_text(env, &current) {
Some(text) => parts.push(text),
None => break,
}
let cause = env
.call_method(&current, "getCause", "()Ljava/lang/Throwable;", &[])
.and_then(|v| v.l());
match cause {
Ok(next) if !next.is_null() && !env.is_same_object(&next, &current).unwrap_or(true) => {
current = next;
}
_ => break,
}
}
let _ = env.exception_clear();
(!parts.is_empty()).then(|| parts.join(", caused by: "))
}

fn throwable_text(env: &mut JNIEnv, thrown: &JObject) -> Option<String> {
let text = env
.call_method(thrown, "toString", "()Ljava/lang/String;", &[])
.and_then(|v| v.l())
.ok()?;
let text: String = env.get_string(&JString::from(text)).ok()?.into();
Some(text.split_whitespace().collect::<Vec<_>>().join(" "))
}

/// Resolve an app class by dotted binary name (e.g. `com.voltius.app.VoltiusDownloads`) via
/// the application class loader captured at `nativeInit`. Use this instead of
/// `env.find_class`/`call_static_method(<str>, ..)` from threads Rust attaches itself (async
Expand Down
23 changes: 21 additions & 2 deletions src/components/layout/SplashScreen.appLock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ const h = vi.hoisted(() => ({
available: true,
exists: true,
mode: null as string | null,
accountId: "acc-1" as string | null,
launchLock: null as string | null,
secret: "plain",
authProps: null as null | { isLocked: boolean; systemAuth?: boolean },
authProps: null as null | { isLocked: boolean; systemAuth?: boolean; vaultUnreadable?: boolean },
}));

vi.mock("react-i18next", () => ({
Expand All @@ -24,6 +25,7 @@ vi.mock("@/services/account", () => ({
setAppLock: vi.fn(async () => {}),
isServerMode: vi.fn(async () => h.mode === "server"),
getAccountMode: vi.fn(async () => h.mode),
getAccountId: vi.fn(async () => h.accountId),
}));
vi.mock("@/services/appLock", () => ({
systemAuthAvailable: vi.fn(async () => h.available),
Expand All @@ -35,7 +37,7 @@ vi.mock("@/stores/securityStore", () => ({
}));
vi.mock("@/services/vault", () => ({ getVaultStatus: vi.fn(async () => ({ exists: h.exists })) }));
vi.mock("./AuthPage", () => ({
default: (p: { isLocked: boolean; systemAuth?: boolean }) => { h.authProps = p; return <div>auth-page</div>; },
default: (p: { isLocked: boolean; systemAuth?: boolean; vaultUnreadable?: boolean }) => { h.authProps = p; return <div>auth-page</div>; },
}));
vi.mock("./LogoBadge", () => ({ default: () => <div /> }));

Expand All @@ -47,6 +49,7 @@ beforeEach(() => {
h.systemAuthUnlock = false;
h.exists = true;
h.mode = null;
h.accountId = "acc-1";
h.launchLock = null;
h.secret = "plain";
h.authProps = null;
Expand Down Expand Up @@ -154,3 +157,19 @@ test("a declined login on a cloud account with no vault file yet still gets the
await advance(2000);
expect(h.authProps).toMatchObject({ isLocked: true });
});

test("a vault file whose account id is gone from the keychain goes to the unreadable panel, not the password form", async () => {
h.autoLogin.mockResolvedValueOnce("declined");
h.accountId = null;
render(<SplashScreen onReady={() => {}} />);
await advance(2000);
expect(h.authProps).toMatchObject({ isLocked: true, vaultUnreadable: true });
});

test("a vault lock over a vault whose account id is gone goes to the unreadable panel", async () => {
h.lock = "vault";
h.accountId = null;
render(<SplashScreen onReady={() => {}} />);
await advance(2000);
expect(h.authProps).toMatchObject({ isLocked: true, vaultUnreadable: true });
});
1 change: 1 addition & 0 deletions src/components/layout/SplashScreen.keychainHint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock("@/services/account", () => ({
setAppLock: vi.fn(async () => {}),
isServerMode: vi.fn(async () => false),
getAccountMode: vi.fn(async () => "local"),
getAccountId: vi.fn(async () => "acc-1"),
}));
vi.mock("@/services/appLock", () => ({ systemAuthAvailable: async () => false, lockOnLaunchIfIdle: async () => null }));
vi.mock("@/services/vault", () => ({ getVaultStatus: vi.fn(async () => ({ exists: true })) }));
Expand Down
24 changes: 15 additions & 9 deletions src/components/layout/SplashScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useFolderStore } from "@/stores/folderStore";
import { useSnippetStore } from "@/stores/snippetStore";
import { useSnippetFolderStore } from "@/stores/snippetFolderStore";
import { usePortForwardingStore } from "@/stores/portForwardingStore";
import { autoLogin, getAccountMode, getAppLock, isServerMode, setAppLock } from "@/services/account";
import { autoLogin, getAccountId, getAccountMode, getAppLock, isServerMode, setAppLock } from "@/services/account";
import { lockOnLaunchIfIdle, systemAuthAvailable } from "@/services/appLock";
import { secretState } from "@/services/vaultSecret";
import { useAppLockStore } from "@/stores/appLockStore";
Expand Down Expand Up @@ -44,9 +44,13 @@ function keepSwitcherFresh(): void {
saveCurrentAccount().catch((e) => console.warn("[splash] could not save this account to the switcher:", e));
}

type UnlockPhase = "auth-locked" | "auth-unreadable" | "auth-first-launch";

// 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 || canLockVault(await getAccountMode());
// A vault whose account id is gone from the keychain (restored without it) can't take a password.
async function unlockPhase(): Promise<UnlockPhase> {
if ((await getVaultStatus()).exists) return (await getAccountId()) ? "auth-locked" : "auth-unreadable";
return canLockVault(await getAccountMode()) ? "auth-locked" : "auth-first-launch";
}

export default function SplashScreen({ onReady }: Props) {
Expand All @@ -62,6 +66,12 @@ export default function SplashScreen({ onReady }: Props) {
const setStep = (id: string, status: StepStatus, label?: string) =>
setSteps((prev) => prev.map((s) => s.id === id ? { ...s, status, ...(label ? { label } : {}) } : s));

const showUnlockPhase = (next: UnlockPhase, lockedLabel: string) => {
if (next === "auth-unreadable") setStep("vault", "error", t("layout.splash.vaultUnreadable"));
else setStep("vault", "done", next === "auth-locked" ? lockedLabel : t("layout.splash.firstLaunch"));
setPhase(next);
};

useEffect(() => {
async function init() {
setStep("init", "running");
Expand All @@ -75,9 +85,7 @@ export default function SplashScreen({ onReady }: Props) {
const sealed = (await secretState().catch(() => "none")) === "sealed";
setSystemAuth(sealed || (useSecurityStore.getState().systemAuthUnlock && (await systemAuthAvailable())));
try {
const locked = await hasAccountToUnlock();
setStep("vault", "done", locked ? t("layout.splash.vaultLocked") : t("layout.splash.firstLaunch"));
setPhase(locked ? "auth-locked" : "auth-first-launch");
showUnlockPhase(await unlockPhase(), t("layout.splash.vaultLocked"));
} catch {
setStep("vault", "error", t("layout.splash.vaultCheckFailed"));
setPhase("auth-first-launch");
Expand Down Expand Up @@ -110,9 +118,7 @@ export default function SplashScreen({ onReady }: Props) {

// autoLogin failed — check if a vault already exists (locked) or first launch
try {
const locked = await hasAccountToUnlock();
setStep("vault", "done", locked ? t("layout.splash.vaultFound") : t("layout.splash.firstLaunch"));
setPhase(locked ? "auth-locked" : "auth-first-launch");
showUnlockPhase(await unlockPhase(), t("layout.splash.vaultFound"));
} catch {
setStep("vault", "error", t("layout.splash.vaultCheckFailed"));
setPhase("auth-first-launch");
Expand Down
4 changes: 4 additions & 0 deletions src/services/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,10 @@ export async function getAccountMode(): Promise<string | null> {
return keychainGet("mode");
}

export async function getAccountId(): Promise<string | null> {
return keychainGet("account_id");
}

export async function getCurrentUserEmail(): Promise<string | null> {
return keychainGet("email");
}
Expand Down
Loading