From 662be3d87c922c43ab9f1acf3e0c7925862f6313 Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 15:05:43 +0000 Subject: [PATCH 01/12] feat(crypto): public key of a stored x25519 private key --- src-tauri/src/commands/team_crypto.rs | 17 +++++++++++++++++ src-tauri/src/lib.rs | 1 + 2 files changed, 18 insertions(+) diff --git a/src-tauri/src/commands/team_crypto.rs b/src-tauri/src/commands/team_crypto.rs index 1c179c990..91e281856 100644 --- a/src-tauri/src/commands/team_crypto.rs +++ b/src-tauri/src/commands/team_crypto.rs @@ -36,6 +36,15 @@ pub fn derive_x25519_keypair(enc_key: Vec) -> Result) -> Result { + let bytes: [u8; 32] = private_key + .as_slice() + .try_into() + .map_err(|_| "x25519 private key must be 32 bytes".to_string())?; + Ok(STANDARD.encode(PublicKey::from(&StaticSecret::from(bytes)).as_bytes())) +} + // ─── Session key generation ─────────────────────────────────────────────────── const NONCE_LEN: usize = 24; @@ -142,6 +151,14 @@ mod tests { (kp.private_key, kp.public_key) } + #[test] + fn public_from_private_matches_the_derived_pair() { + let kp = derive_x25519_keypair(b"seed".to_vec()).unwrap(); + let private = STANDARD.decode(&kp.private_key).unwrap(); + assert_eq!(x25519_public_from_private(private).unwrap(), kp.public_key); + assert!(x25519_public_from_private(vec![1u8; 31]).is_err()); + } + #[test] fn wrap_then_unwrap_round_trips() { let (a_priv, a_pub) = keypair(b"alice-enc-key"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4ed9e6cd8..388de6b70 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -332,6 +332,7 @@ pub fn run() { commands::crypto::unwrap_user_secrets_cmd, commands::crypto::generate_user_secrets_cmd, commands::team_crypto::derive_x25519_keypair, + commands::team_crypto::x25519_public_from_private, commands::team_crypto::generate_session_key, commands::team_crypto::x25519_wrap_key, commands::team_crypto::x25519_unwrap_key, From a8579babdf3ddda2912c6aa9a66c5a21ddedf1ff Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 15:09:17 +0000 Subject: [PATCH 02/12] feat(sync): learn whether the server guards wrapped secrets uploads --- src/services/serverCapabilities.test.ts | 14 ++++++++++++++ src/services/serverCapabilities.ts | 10 +++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/services/serverCapabilities.test.ts b/src/services/serverCapabilities.test.ts index 522c04933..ead8a3cd3 100644 --- a/src/services/serverCapabilities.test.ts +++ b/src/services/serverCapabilities.test.ts @@ -6,6 +6,7 @@ vi.mock("@/lib/logger", () => ({ logFailure: () => h.logged })); import { refreshServerCapabilities, resetServerCapabilities, serverLacksSecretType, noteRefusedSecretType, + serverSupportsWrappedSecretsCas, } from "./serverCapabilities"; beforeEach(() => { @@ -80,3 +81,16 @@ test("a reset forgets the previous server", async () => { await refreshServerCapabilities(); expect(h.fetchServerMeta).toHaveBeenCalledTimes(2); }); + +test("a server that advertises wrapped-secrets compare-and-swap is trusted with identity repairs", async () => { + h.fetchServerMeta.mockResolvedValue({ wrapped_secrets_cas: true }); + expect(await serverSupportsWrappedSecretsCas()).toBe(true); +}); + +test("a server that predates wrapped-secrets compare-and-swap, or cannot be reached, is not", async () => { + h.fetchServerMeta.mockResolvedValue({}); + expect(await serverSupportsWrappedSecretsCas()).toBe(false); + resetServerCapabilities(); + h.fetchServerMeta.mockRejectedValue(new Error("offline")); + expect(await serverSupportsWrappedSecretsCas()).toBe(false); +}); diff --git a/src/services/serverCapabilities.ts b/src/services/serverCapabilities.ts index fa3da613e..83ac31453 100644 --- a/src/services/serverCapabilities.ts +++ b/src/services/serverCapabilities.ts @@ -13,6 +13,7 @@ interface ServerCapabilities { refusedSecretTypes: TeamSecretType[]; twoFactor: boolean; passkeys: boolean; + wrappedSecretsCas: boolean; } const UNKNOWN: ServerCapabilities = { @@ -20,6 +21,7 @@ const UNKNOWN: ServerCapabilities = { refusedSecretTypes: [], twoFactor: false, passkeys: false, + wrappedSecretsCas: false, }; const useServerCapabilities = create(() => UNKNOWN); @@ -28,13 +30,14 @@ let inFlight: Promise | null = null; async function loadCapabilities(): Promise { try { - const { team_secret_types: types, two_factor, passkeys } = await fetchServerMeta( + const { team_secret_types: types, two_factor, passkeys, wrapped_secrets_cas } = await fetchServerMeta( "common.error.failedToLoadServerCapabilities", ); fetchedAt = Date.now(); useServerCapabilities.setState({ twoFactor: two_factor === true, passkeys: passkeys === true, + wrappedSecretsCas: wrapped_secrets_cas === true, ...(Array.isArray(types) ? { advertisedSecretTypes: types.filter((t): t is string => typeof t === "string"), refusedSecretTypes: [] } : { advertisedSecretTypes: null }), @@ -84,3 +87,8 @@ export function useServerSecondFactors(): { totp: boolean; passkeys: boolean } { const passkeys = useServerCapabilities((s) => s.passkeys); return { totp, passkeys }; } + +export async function serverSupportsWrappedSecretsCas(): Promise { + await refreshServerCapabilities(); + return useServerCapabilities.getState().wrappedSecretsCas; +} From 8cf7d03db8198a80bf16443059d80ac1e1e570ca Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 15:11:19 +0000 Subject: [PATCH 03/12] feat(teams): resolve the account identity from its stored key The identity was derived from whatever key opened this device's vault, so linked and migrated accounts had a different one on every device that used the DEK. The stored x25519 key now wins once the roster confirms it, and a device that finds the right derived key writes it back. --- src/services/identity.test.ts | 166 ++++++++++++++++++++++++++++++++++ src/services/identity.ts | 92 +++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 src/services/identity.test.ts create mode 100644 src/services/identity.ts diff --git a/src/services/identity.test.ts b/src/services/identity.test.ts new file mode 100644 index 000000000..c7d2b927e --- /dev/null +++ b/src/services/identity.test.ts @@ -0,0 +1,166 @@ +import { test, expect, vi, beforeEach } from "vitest"; + +const h = vi.hoisted(() => ({ + invoke: vi.fn(), + vaultKey: [5, 5, 5] as number[] | null, + myUserId: "me" as string | null, + published: null as string | null, + cas: true, + fetch: vi.fn(), + store: {} as Record, + seq: [] as string[], +})); +vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke })); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); +vi.mock("@/services/vault", () => ({ getVaultKey: () => h.vaultKey })); +vi.mock("@/services/teamService", () => ({ + getMyUserId: async () => h.myUserId, + getUserPublicKey: async () => { + h.seq.push("roster"); + return h.published ? { public_key: h.published } : null; + }, +})); +vi.mock("@/services/serverCapabilities", () => ({ serverSupportsWrappedSecretsCas: async () => h.cas })); +vi.mock("@/services/authTokens", () => ({ getServerUrl: async () => "https://srv" })); +vi.mock("@/services/authFetch", () => ({ fetchAuthJson: h.fetch })); + +import { clearIdentityCache, ensureIdentity } from "./identity"; +import { useVaultKeysStore } from "@/stores/vaultKeysStore"; + +const KEK = [9, 9, 9]; +const DEK = [1, 1, 1]; +const STORED = [2, 2, 2]; +// A derived pair is named after its seed; a stored private key's public key is "PUB_OF_". +const derivedPub = (seed: number[]) => `DERIVED_FROM_${seed}`; + +beforeEach(() => { + clearIdentityCache(); + useVaultKeysStore.getState().clear(); + h.vaultKey = [5, 5, 5]; + h.myUserId = "me"; + h.published = null; + h.cas = true; + h.store = { wrapped_user_secrets: "OLD_WRAPPED" }; + h.seq = []; + h.fetch.mockReset(); + h.fetch.mockImplementation(async (url: string, init: { body: string }) => { + h.seq.push(`PUT ${url}`); + return { ok: true, status: 204, body: JSON.parse(init.body) }; + }); + h.invoke.mockReset(); + h.invoke.mockImplementation(async (cmd: string, args: Record) => { + switch (cmd) { + case "derive_x25519_keypair": + return { public_key: derivedPub(args.encKey as number[]), private_key: btoa(`priv-${args.encKey}`) }; + case "x25519_public_from_private": + return `PUB_OF_${args.privateKey}`; + case "wrap_user_secrets_cmd": + return `WRAPPED(${args.x25519Private})`; + case "keychain_get": + h.seq.push(`read ${args.key}`); + return h.store[args.key as string] ?? null; + case "keychain_set": + h.store[args.key as string] = args.value as string; + return undefined; + default: + return undefined; + } + }); +}); + +const cloudSession = () => useVaultKeysStore.getState().set({ kek: KEK, dek: DEK, x25519Private: STORED }); +const bytes = (b64: string) => Array.from(atob(b64), (c) => c.charCodeAt(0)); +const sentBody = () => JSON.parse(h.fetch.mock.calls[0][1].body); + +test("a stored identity the roster agrees with is used as is", async () => { + cloudSession(); + h.published = `PUB_OF_${STORED}`; + const id = await ensureIdentity(); + expect(id).toMatchObject({ publicKey: `PUB_OF_${STORED}`, proven: true, stored: true }); + expect(h.fetch).not.toHaveBeenCalled(); +}); + +test("a cloud account's random stored key is replaced by the dek-derived identity it published", async () => { + cloudSession(); + h.published = derivedPub(DEK); + const id = await ensureIdentity(); + expect(id).toMatchObject({ publicKey: derivedPub(DEK), proven: true, stored: true }); + expect(sentBody()).toEqual({ + wrapped_user_secrets: `WRAPPED(${bytes(btoa(`priv-${DEK}`))})`, + expected_wrapped_user_secrets: "OLD_WRAPPED", + }); + expect(h.store.wrapped_user_secrets).toBe(`WRAPPED(${bytes(btoa(`priv-${DEK}`))})`); + expect(useVaultKeysStore.getState().x25519Private).toEqual(bytes(btoa(`priv-${DEK}`))); +}); + +test("a linked account's dek device finds the identity its kek published", async () => { + cloudSession(); + h.vaultKey = DEK; + h.published = derivedPub(KEK); + const id = await ensureIdentity(); + expect(id).toMatchObject({ publicKey: derivedPub(KEK), proven: true, stored: true }); + expect(useVaultKeysStore.getState().identityUnproven).toBe(false); +}); + +test("an account with nothing published takes its vault key's identity and stores it", async () => { + cloudSession(); + const id = await ensureIdentity(); + expect(id).toMatchObject({ publicKey: derivedPub([5, 5, 5]), proven: true, stored: true }); +}); + +test("no candidate matching the roster leaves the session unproven and writes nothing", async () => { + cloudSession(); + h.published = "SOMEONE_ELSE"; + const id = await ensureIdentity(); + expect(id).toMatchObject({ proven: false, stored: false }); + expect(useVaultKeysStore.getState().identityUnproven).toBe(true); + expect(h.fetch).not.toHaveBeenCalled(); +}); + +test("a server without compare-and-swap gets no write; the identity still holds for the session", async () => { + cloudSession(); + h.cas = false; + h.published = derivedPub(DEK); + expect(await ensureIdentity()).toMatchObject({ publicKey: derivedPub(DEK), proven: true, stored: false }); + expect(h.fetch).not.toHaveBeenCalled(); + expect(h.store.wrapped_user_secrets).toBe("OLD_WRAPPED"); +}); + +test("a 409 leaves the keychain and the store as they were", async () => { + cloudSession(); + h.published = derivedPub(DEK); + h.fetch.mockResolvedValue({ ok: false, status: 409 }); + expect(await ensureIdentity()).toMatchObject({ proven: true, stored: false }); + expect(h.store.wrapped_user_secrets).toBe("OLD_WRAPPED"); + expect(useVaultKeysStore.getState().x25519Private).toEqual(STORED); +}); + +test("the expected value is captured before the roster fetch", async () => { + cloudSession(); + h.published = derivedPub(DEK); + await ensureIdentity(); + expect(h.seq.indexOf("read wrapped_user_secrets")).toBeLessThan(h.seq.indexOf("roster")); +}); + +test("a local session derives from its vault key without asking the server", async () => { + const id = await ensureIdentity(); + expect(id).toMatchObject({ publicKey: derivedPub([5, 5, 5]), proven: false, stored: false }); + expect(h.seq).not.toContain("roster"); +}); + +test("the lookup runs once per session", async () => { + cloudSession(); + h.published = `PUB_OF_${STORED}`; + await ensureIdentity(); + await ensureIdentity(); + expect(h.seq.filter((s) => s === "roster")).toHaveLength(1); + + clearIdentityCache(); + h.myUserId = null; + await expect(ensureIdentity()).resolves.toMatchObject({ proven: false }); +}); + +test("a locked vault has no identity", async () => { + h.vaultKey = null; + await expect(ensureIdentity()).rejects.toThrow("common.error.vaultLocked"); +}); diff --git a/src/services/identity.ts b/src/services/identity.ts new file mode 100644 index 000000000..9170789bd --- /dev/null +++ b/src/services/identity.ts @@ -0,0 +1,92 @@ +import { invoke } from "@/lib/invoke"; +import i18n from "@/i18n"; +import { getVaultKey } from "@/services/vault"; +import { useVaultKeysStore } from "@/stores/vaultKeysStore"; +import * as teamService from "@/services/teamService"; +import { fetchAuthJson } from "@/services/authFetch"; +import { getServerUrl } from "@/services/authTokens"; +import { serverSupportsWrappedSecretsCas } from "@/services/serverCapabilities"; +import { base64ToBytes, bytesToBase64 } from "@/utils/base64"; + +export interface Identity { + privateKey: string; + publicKey: string; + /** Its public key is the roster's, or the roster has none yet. */ + proven: boolean; + /** The account's wrapped secrets carry it, so no device needs a vault key to find it again. */ + stored: boolean; +} + +interface Keypair { + privateKey: string; + publicKey: string; +} + +type SessionKeys = { kek: number[] | null; dek: number[] | null; x25519Private: number[] | null }; + +let memo: { vaultKey: number[]; x25519Private: number[] | null; identity: Promise } | null = null; + +export function clearIdentityCache(): void { + memo = null; +} + +async function derive(seed: number[]): Promise { + const r = await invoke<{ public_key: string; private_key: string }>("derive_x25519_keypair", { encKey: seed }); + return { privateKey: r.private_key, publicKey: r.public_key }; +} + +async function fromPrivate(privateKey: number[]): Promise { + const publicKey = await invoke("x25519_public_from_private", { privateKey }); + return { privateKey: bytesToBase64(privateKey), publicKey }; +} + +async function store(keys: { kek: number[]; dek: number[] }, identity: Keypair, expected: string | null): Promise { + const serverUrl = await getServerUrl(); + if (!serverUrl || !(await serverSupportsWrappedSecretsCas())) return false; + const x25519Private = Array.from(base64ToBytes(identity.privateKey)); + const next = await invoke("wrap_user_secrets_cmd", { ...keys, x25519Private }); + const res = await fetchAuthJson(`${serverUrl}/v1/auth/wrapped-user-secrets`, { + method: "PUT", + body: JSON.stringify({ wrapped_user_secrets: next, expected_wrapped_user_secrets: expected }), + }).catch(() => null); + if (!res?.ok) return false; + await invoke("keychain_set", { key: "wrapped_user_secrets", value: next }); + useVaultKeysStore.getState().set({ ...keys, x25519Private }); + return true; +} + +async function resolve(vaultKey: number[], { kek, dek, x25519Private }: SessionKeys): Promise { + const unproven = async (): Promise => ({ ...(await derive(vaultKey)), proven: false, stored: false }); + if (!kek || !dek) return unproven(); + // Read before the roster: a lookup outliving an account switch must fail the server's compare-and-swap. + const expected = await invoke("keychain_get", { key: "wrapped_user_secrets" }); + const myUserId = await teamService.getMyUserId(); + if (!myUserId) return unproven(); + const published = (await teamService.getUserPublicKey(myUserId))?.public_key ?? null; + + if (x25519Private) { + const stored = await fromPrivate(x25519Private); + if (stored.publicKey === published) return { ...stored, proven: true, stored: true }; + } + const candidates = await Promise.all([vaultKey, kek, dek].map(derive)); + const match = published ? candidates.find((c) => c.publicKey === published) : candidates[0]; + if (!match) { + useVaultKeysStore.getState().markIdentityUnproven(); + return unproven(); + } + return { ...match, proven: true, stored: await store({ kek, dek }, match, expected) }; +} + +/** The account's X25519 identity, resolved once per vault key and stored keypair. */ +export function ensureIdentity(): Promise { + const vaultKey = getVaultKey(); + if (!vaultKey) return Promise.reject(new Error(i18n.t("common.error.vaultLocked"))); + const keys = useVaultKeysStore.getState(); + if (memo && memo.vaultKey === vaultKey && memo.x25519Private === keys.x25519Private) return memo.identity; + const identity = resolve(vaultKey, keys); + memo = { vaultKey, x25519Private: keys.x25519Private, identity }; + identity.catch(() => { + if (memo?.identity === identity) memo = null; + }); + return identity; +} From 581fb1e92726c3fd691447dc2da867ccc7606dc1 Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 15:18:03 +0000 Subject: [PATCH 04/12] fix(teams): keep identity repair from crossing account switches Snapshot the session before the roster lookup and re-check it around the write, never write without a captured wrapped value, keep a stored key when the roster has none, log repair failures, and share the derive/wrap commands with account.ts. --- src/services/account.ts | 10 +--- src/services/crypto/identityKeys.ts | 9 +++ src/services/identity.test.ts | 91 +++++++++++++++++++++++++++-- src/services/identity.ts | 66 +++++++++++++++------ 4 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 src/services/crypto/identityKeys.ts diff --git a/src/services/account.ts b/src/services/account.ts index 435feeb8e..62b1bac2d 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -1,3 +1,4 @@ +import { deriveX25519Keypair, wrapUserSecrets } from "@/services/crypto/identityKeys"; import { invoke } from "@/lib/invoke"; import { featureDisabledError } from "@/services/featureDisabled"; import i18n from "@/i18n"; @@ -48,19 +49,10 @@ async function generateUserSecrets(): Promise { return invoke("generate_user_secrets_cmd"); } -async function wrapUserSecrets(kek: number[], dek: number[], x25519Private: number[]): Promise { - return invoke("wrap_user_secrets_cmd", { kek, dek, x25519Private }); -} - async function unwrapUserSecrets(kek: number[], wrappedB64: string): Promise { return invoke("unwrap_user_secrets_cmd", { kek, wrappedB64 }); } -/** The identity every wrap and unwrap uses; `getMyX25519Keypair` derives the same. */ -async function deriveX25519Keypair(encKey: number[]): Promise<{ public_key: string; private_key: string }> { - return invoke<{ public_key: string; private_key: string }>("derive_x25519_keypair", { encKey }); -} - async function registerOnServer(args: { serverUrl: string; email: string; diff --git a/src/services/crypto/identityKeys.ts b/src/services/crypto/identityKeys.ts new file mode 100644 index 000000000..95c71954a --- /dev/null +++ b/src/services/crypto/identityKeys.ts @@ -0,0 +1,9 @@ +import { invoke } from "@/lib/invoke"; + +export async function deriveX25519Keypair(encKey: number[]): Promise<{ public_key: string; private_key: string }> { + return invoke<{ public_key: string; private_key: string }>("derive_x25519_keypair", { encKey }); +} + +export async function wrapUserSecrets(kek: number[], dek: number[], x25519Private: number[]): Promise { + return invoke("wrap_user_secrets_cmd", { kek, dek, x25519Private }); +} diff --git a/src/services/identity.test.ts b/src/services/identity.test.ts index c7d2b927e..90a876a08 100644 --- a/src/services/identity.test.ts +++ b/src/services/identity.test.ts @@ -9,6 +9,7 @@ const h = vi.hoisted(() => ({ fetch: vi.fn(), store: {} as Record, seq: [] as string[], + rosterGate: undefined as Promise | undefined, })); vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke })); vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); @@ -17,6 +18,7 @@ vi.mock("@/services/teamService", () => ({ getMyUserId: async () => h.myUserId, getUserPublicKey: async () => { h.seq.push("roster"); + await h.rosterGate; return h.published ? { public_key: h.published } : null; }, })); @@ -42,6 +44,7 @@ beforeEach(() => { h.cas = true; h.store = { wrapped_user_secrets: "OLD_WRAPPED" }; h.seq = []; + h.rosterGate = undefined; h.fetch.mockReset(); h.fetch.mockImplementation(async (url: string, init: { body: string }) => { h.seq.push(`PUT ${url}`); @@ -102,12 +105,6 @@ test("a linked account's dek device finds the identity its kek published", async expect(useVaultKeysStore.getState().identityUnproven).toBe(false); }); -test("an account with nothing published takes its vault key's identity and stores it", async () => { - cloudSession(); - const id = await ensureIdentity(); - expect(id).toMatchObject({ publicKey: derivedPub([5, 5, 5]), proven: true, stored: true }); -}); - test("no candidate matching the roster leaves the session unproven and writes nothing", async () => { cloudSession(); h.published = "SOMEONE_ELSE"; @@ -164,3 +161,85 @@ test("a locked vault has no identity", async () => { h.vaultKey = null; await expect(ensureIdentity()).rejects.toThrow("common.error.vaultLocked"); }); + +const deferred = () => { + let release!: () => void; + const promise = new Promise((r) => (release = r)); + return { promise, release }; +}; +const switchAccount = () => { + h.vaultKey = [7, 7, 7]; + useVaultKeysStore.getState().set({ kek: [8, 8, 8], dek: [4, 4, 4], x25519Private: [3, 3, 3] }); +}; + +test("an account switch during the roster fetch leaves the new session alone", async () => { + cloudSession(); + h.published = derivedPub(DEK); + const gate = deferred(); + h.rosterGate = gate.promise; + const pending = ensureIdentity(); + await vi.waitFor(() => expect(h.seq).toContain("roster")); + switchAccount(); + gate.release(); + await expect(pending).resolves.toMatchObject({ proven: false, stored: false }); + expect(useVaultKeysStore.getState().identityUnproven).toBe(false); + expect(h.fetch).not.toHaveBeenCalled(); + expect(h.store.wrapped_user_secrets).toBe("OLD_WRAPPED"); +}); + +test("an account switch during the PUT writes nothing locally", async () => { + cloudSession(); + h.published = derivedPub(DEK); + h.fetch.mockImplementation(async () => { + switchAccount(); + return { ok: true, status: 204 }; + }); + await expect(ensureIdentity()).resolves.toMatchObject({ proven: true, stored: false }); + expect(h.store.wrapped_user_secrets).toBe("OLD_WRAPPED"); + expect(useVaultKeysStore.getState().x25519Private).toEqual([3, 3, 3]); +}); + +test("a rejected PUT leaves a proven identity unstored and writes nothing", async () => { + cloudSession(); + h.published = derivedPub(DEK); + h.fetch.mockRejectedValue(new Error("offline")); + await expect(ensureIdentity()).resolves.toMatchObject({ proven: true, stored: false }); + expect(h.store.wrapped_user_secrets).toBe("OLD_WRAPPED"); + expect(useVaultKeysStore.getState().x25519Private).toEqual(STORED); +}); + +test("no stored wrapped secrets means no write", async () => { + cloudSession(); + h.store = {}; + h.published = derivedPub(DEK); + await expect(ensureIdentity()).resolves.toMatchObject({ proven: true, stored: false }); + expect(h.fetch).not.toHaveBeenCalled(); +}); + +test("an unpublished roster keeps the stored identity without a write", async () => { + cloudSession(); + await expect(ensureIdentity()).resolves.toMatchObject({ publicKey: `PUB_OF_${STORED}`, proven: true, stored: true }); + expect(h.fetch).not.toHaveBeenCalled(); +}); + +test("an unpublished roster and no stored key store the vault key identity", async () => { + cloudSession(); + useVaultKeysStore.setState({ x25519Private: null }); + await expect(ensureIdentity()).resolves.toMatchObject({ publicKey: derivedPub([5, 5, 5]), proven: true, stored: true }); + expect(sentBody()).toEqual({ + wrapped_user_secrets: `WRAPPED(${bytes(btoa(`priv-${[5, 5, 5]}`))})`, + expected_wrapped_user_secrets: "OLD_WRAPPED", + }); +}); + +test("a wrap failure still resolves a proven identity", async () => { + cloudSession(); + h.published = derivedPub(DEK); + const impl = h.invoke.getMockImplementation()!; + h.invoke.mockImplementation(async (cmd: string, args: Record) => { + if (cmd === "wrap_user_secrets_cmd") throw new Error("wrap"); + return impl(cmd, args); + }); + await expect(ensureIdentity()).resolves.toMatchObject({ proven: true, stored: false }); + expect(h.fetch).not.toHaveBeenCalled(); +}); diff --git a/src/services/identity.ts b/src/services/identity.ts index 9170789bd..97eefa2ee 100644 --- a/src/services/identity.ts +++ b/src/services/identity.ts @@ -1,5 +1,7 @@ import { invoke } from "@/lib/invoke"; import i18n from "@/i18n"; +import { logFailure } from "@/lib/logger"; +import { deriveX25519Keypair, wrapUserSecrets } from "@/services/crypto/identityKeys"; import { getVaultKey } from "@/services/vault"; import { useVaultKeysStore } from "@/stores/vaultKeysStore"; import * as teamService from "@/services/teamService"; @@ -31,7 +33,7 @@ export function clearIdentityCache(): void { } async function derive(seed: number[]): Promise { - const r = await invoke<{ public_key: string; private_key: string }>("derive_x25519_keypair", { encKey: seed }); + const r = await deriveX25519Keypair(seed); return { privateKey: r.private_key, publicKey: r.public_key }; } @@ -40,41 +42,66 @@ async function fromPrivate(privateKey: number[]): Promise { return { privateKey: bytesToBase64(privateKey), publicKey }; } -async function store(keys: { kek: number[]; dek: number[] }, identity: Keypair, expected: string | null): Promise { +async function store( + keys: { kek: number[]; dek: number[] }, + identity: Keypair, + expected: string | null, + stillCurrent: () => boolean, +): Promise { + if (!expected || !stillCurrent()) return false; const serverUrl = await getServerUrl(); if (!serverUrl || !(await serverSupportsWrappedSecretsCas())) return false; const x25519Private = Array.from(base64ToBytes(identity.privateKey)); - const next = await invoke("wrap_user_secrets_cmd", { ...keys, x25519Private }); + const next = await wrapUserSecrets(keys.kek, keys.dek, x25519Private); const res = await fetchAuthJson(`${serverUrl}/v1/auth/wrapped-user-secrets`, { method: "PUT", body: JSON.stringify({ wrapped_user_secrets: next, expected_wrapped_user_secrets: expected }), - }).catch(() => null); - if (!res?.ok) return false; + }).catch((e) => { + logFailure("identity: store wrapped secrets")(e); + return null; + }); + if (!res) return false; + if (!res.ok) { + logFailure("identity: store wrapped secrets")(new Error(`status ${res.status}`)); + return false; + } + if (!stillCurrent()) return false; await invoke("keychain_set", { key: "wrapped_user_secrets", value: next }); useVaultKeysStore.getState().set({ ...keys, x25519Private }); return true; } -async function resolve(vaultKey: number[], { kek, dek, x25519Private }: SessionKeys): Promise { +async function resolve( + vaultKey: number[], + { kek, dek, x25519Private }: SessionKeys, + flags: { noUser: boolean }, +): Promise { const unproven = async (): Promise => ({ ...(await derive(vaultKey)), proven: false, stored: false }); if (!kek || !dek) return unproven(); + const stillCurrent = () => getVaultKey() === vaultKey && useVaultKeysStore.getState().kek === kek; // Read before the roster: a lookup outliving an account switch must fail the server's compare-and-swap. const expected = await invoke("keychain_get", { key: "wrapped_user_secrets" }); const myUserId = await teamService.getMyUserId(); - if (!myUserId) return unproven(); + if (!myUserId) { + flags.noUser = true; + return unproven(); + } const published = (await teamService.getUserPublicKey(myUserId))?.public_key ?? null; + if (!stillCurrent()) return unproven(); - if (x25519Private) { - const stored = await fromPrivate(x25519Private); - if (stored.publicKey === published) return { ...stored, proven: true, stored: true }; - } + const held = x25519Private ? await fromPrivate(x25519Private) : null; + if (held && (!published || held.publicKey === published)) return { ...held, proven: true, stored: true }; const candidates = await Promise.all([vaultKey, kek, dek].map(derive)); const match = published ? candidates.find((c) => c.publicKey === published) : candidates[0]; if (!match) { - useVaultKeysStore.getState().markIdentityUnproven(); + if (stillCurrent()) useVaultKeysStore.getState().markIdentityUnproven(); return unproven(); } - return { ...match, proven: true, stored: await store({ kek, dek }, match, expected) }; + const stored = await store({ kek, dek }, match, expected, stillCurrent).catch((e) => { + logFailure("identity: store")(e); + return false; + }); + return { ...match, proven: true, stored }; } /** The account's X25519 identity, resolved once per vault key and stored keypair. */ @@ -83,10 +110,13 @@ export function ensureIdentity(): Promise { if (!vaultKey) return Promise.reject(new Error(i18n.t("common.error.vaultLocked"))); const keys = useVaultKeysStore.getState(); if (memo && memo.vaultKey === vaultKey && memo.x25519Private === keys.x25519Private) return memo.identity; - const identity = resolve(vaultKey, keys); - memo = { vaultKey, x25519Private: keys.x25519Private, identity }; - identity.catch(() => { - if (memo?.identity === identity) memo = null; - }); + const flags = { noUser: false }; + const identity = resolve(vaultKey, keys, flags); + const entry = { vaultKey, x25519Private: keys.x25519Private, identity }; + memo = entry; + const forget = () => { + if (memo === entry) memo = null; + }; + identity.then(() => flags.noUser && forget(), forget); return identity; } From 56bf0d6c2421514d06a04fb6e92e862171a53f6e Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 15:21:28 +0000 Subject: [PATCH 05/12] fix(teams): wrap and unwrap team keys with the resolved identity --- .../multiplayerService.publishKey.test.ts | 15 +++++++++++ src/services/multiplayerService.ts | 26 +++---------------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/services/multiplayerService.publishKey.test.ts b/src/services/multiplayerService.publishKey.test.ts index 4b4708e9f..a0b7353a0 100644 --- a/src/services/multiplayerService.publishKey.test.ts +++ b/src/services/multiplayerService.publishKey.test.ts @@ -16,6 +16,8 @@ vi.mock("@/services/teamService", () => ({ getUserPublicKey: h.getUserPublicKey, })); vi.mock("@/services/teamSharing", () => ({ freshPublicKeys: vi.fn() })); +vi.mock("@/services/serverCapabilities", () => ({ serverSupportsWrappedSecretsCas: async () => false })); +vi.mock("@/services/authTokens", () => ({ getServerUrl: async () => null })); import { publishMyPublicKey, clearKeypairCache } from "./multiplayerService"; import { useVaultKeysStore } from "@/stores/vaultKeysStore"; @@ -60,3 +62,16 @@ test("refuses when the session already knows its vault key is unproven", async ( expect(h.getUserPublicKey).not.toHaveBeenCalled(); expect(h.updatePublicKey).not.toHaveBeenCalled(); }); + +test("publishes the stored identity a dek device resolves, not its vault key's", async () => { + useVaultKeysStore.getState().set({ kek: [9], dek: [1], x25519Private: [2] }); + h.invoke.mockImplementation(async (cmd: string, args: Record) => { + if (cmd === "x25519_public_from_private") return "STORED_PUB"; + if (cmd === "keychain_get") return "W"; + if (cmd === "derive_x25519_keypair") return { public_key: `D${args.encKey}`, private_key: "P" }; + return undefined; + }); + h.getUserPublicKey.mockResolvedValue({ public_key: "STORED_PUB" }); + + await expect(publishMyPublicKey()).resolves.toBe("STORED_PUB"); +}); diff --git a/src/services/multiplayerService.ts b/src/services/multiplayerService.ts index c20bbfc29..c6a76a5f9 100644 --- a/src/services/multiplayerService.ts +++ b/src/services/multiplayerService.ts @@ -1,6 +1,6 @@ import { invoke } from "@/lib/invoke"; import i18n from "@/i18n"; -import { getVaultKey } from "@/services/vault"; +import { clearIdentityCache, ensureIdentity } from "@/services/identity"; import { useVaultKeysStore } from "@/stores/vaultKeysStore"; import * as teamService from "@/services/teamService"; import { freshPublicKeys, type InviteTarget } from "@/services/teamSharing"; @@ -72,29 +72,11 @@ export async function decryptData(key: SessionKey, b64: string): Promise { - const encKey = getVaultKey(); - if (!encKey) throw new Error(i18n.t("common.error.vaultLocked")); - if (_cachedKeypair?.from === encKey) { - return { privateKey: _cachedKeypair.privateKey, publicKey: _cachedKeypair.publicKey }; - } - const result = await invoke<{ public_key: string; private_key: string }>( - "derive_x25519_keypair", - { encKey }, - ); - _cachedKeypair = { from: encKey, privateKey: result.private_key, publicKey: result.public_key }; - return { privateKey: result.private_key, publicKey: result.public_key }; + const { privateKey, publicKey } = await ensureIdentity(); + return { privateKey, publicKey }; } /** From e9cb9d5b0dda539489899021007798101e83a6e3 Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 15:24:18 +0000 Subject: [PATCH 06/12] fix(vault): move kek vaults to the dek once the identity is stored A vault on a password-derived key became unopenable after a password change made on another device. With the identity stored in the wrapped secrets, the vault can follow the dek like every cloud-created account. --- src/services/account.serverAuth.test.ts | 52 ++++++++++++++++++++++++- src/services/account.ts | 20 ++++++++-- src/services/sync.ts | 7 ++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/services/account.serverAuth.test.ts b/src/services/account.serverAuth.test.ts index f362ed8fb..3152ca246 100644 --- a/src/services/account.serverAuth.test.ts +++ b/src/services/account.serverAuth.test.ts @@ -26,6 +26,8 @@ const h = vi.hoisted(() => ({ http: {} as Record, dek: null as number[] | null, x25519: null as number[] | null, + kek: null as number[] | null, + ensureIdentity: vi.fn(async () => ({ privateKey: btoa("id"), publicKey: "P", proven: true, stored: true })), emailVerified: false, seq: [] as string[], /** Request bodies by endpoint path, so a test can assert what was sent. */ @@ -53,8 +55,9 @@ vi.mock("./vault", () => ({ vi.mock("@/stores/subscriptionStore", () => ({ useSubscriptionStore: { getState: () => ({ load: h.load, emailVerified: h.emailVerified }) }, })); +vi.mock("@/services/identity", () => ({ ensureIdentity: h.ensureIdentity })); vi.mock("@/stores/vaultKeysStore", () => ({ - useVaultKeysStore: { getState: () => ({ set: h.keysSet, clear: vi.fn(), dek: h.dek, x25519Private: h.x25519 }) }, + useVaultKeysStore: { getState: () => ({ set: h.keysSet, clear: vi.fn(), dek: h.dek, kek: h.kek, x25519Private: h.x25519 }) }, })); import { @@ -65,6 +68,7 @@ import { signInToCloud, linkToCloud, changeMasterPassword, + settleVaultOnDek, changeEmail, refreshSession, refreshVerificationState, @@ -145,6 +149,8 @@ const err = (status: number, body: unknown = {}) => ({ ok: false, status, body } beforeEach(() => { clearSessionRevoked(); + h.kek = null; + h.ensureIdentity.mockClear(); for (const m of [h.invoke, h.appFetch, h.setVaultKey, h.wipeLocalConfig, h.load, h.keysSet]) m.mockReset(); h.readLocalSecrets.mockReset().mockResolvedValue({}); h.getVaultStatus.mockReset(); @@ -1026,3 +1032,47 @@ test("changeMasterPassword sends the code and names a refused one", async () => h.http["/auth/password"] = err(428, { error: "TWO_FACTOR_REQUIRED" }); await expect(changeMasterPassword("pw", "new")).rejects.toThrow("common.error.twoFactorRequired"); }); + +// ─── settleVaultOnDek ──────────────────────────────────────────────────────── + +function kekVaultWithDek() { + cloudSessionOn([9, 9, 9]); + h.kek = [9, 9, 9]; + existingVaultOpenedBy([9, 9, 9]); +} + +test("a kek vault moves to the dek once the identity is stored", async () => { + kekVaultWithDek(); + await settleVaultOnDek(); + expect(h.invoke).toHaveBeenCalledWith("secrets_rekey", { oldEncKey: [9, 9, 9], newEncKey: [1, 1, 1] }); + expect(h.setVaultKey).toHaveBeenLastCalledWith([1, 1, 1]); + await vi.waitFor(() => expect(h.push).toHaveBeenCalled()); +}); + +test("a vault stays on the kek while the identity is not stored", async () => { + kekVaultWithDek(); + h.ensureIdentity.mockResolvedValueOnce({ privateKey: "", publicKey: "", proven: true, stored: false }); + await settleVaultOnDek(); + expect(h.seq).not.toContain("secrets_rekey"); +}); + +test("an offline device keeps its vault and its unlock", async () => { + kekVaultWithDek(); + h.ensureIdentity.mockRejectedValueOnce(new Error("offline")); + await expect(settleVaultOnDek()).resolves.toBeUndefined(); + expect(h.seq).not.toContain("secrets_rekey"); +}); + +test("a vault already on the dek is left alone without asking for the identity", async () => { + cloudSessionOn([1, 1, 1]); + h.kek = [9, 9, 9]; + await settleVaultOnDek(); + expect(h.ensureIdentity).not.toHaveBeenCalled(); +}); + +test("a local account is never settled", async () => { + kekVaultWithDek(); + h.store.mode = "local"; + await settleVaultOnDek(); + expect(h.ensureIdentity).not.toHaveBeenCalled(); +}); diff --git a/src/services/account.ts b/src/services/account.ts index 62b1bac2d..d69270f0e 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -37,6 +37,9 @@ interface UnwrappedUserSecrets { x25519_private: number[]; } +const sameKey = (a: number[] | null, b: number[] | null) => + !!a && !!b && a.length === b.length && a.every((v, i) => v === b[i]); + function isHexEncoded32ByteKey(value: string): boolean { return /^[0-9a-fA-F]{64}$/.test(value); } @@ -288,8 +291,7 @@ export async function isCurrentMasterPassword(password: string): Promise !!key && key.length === kek.length && key.every((b, i) => b === kek[i]); - return same(useVaultKeysStore.getState().kek) || same(getVaultKey()); + return sameKey(useVaultKeysStore.getState().kek, kek) || sameKey(getVaultKey(), kek); } // ─── Account operations ─────────────────────────────────────────────────────── @@ -837,7 +839,7 @@ async function moveVaultToDek(kek: number[], dek: number[]): Promise<(() => Prom if (rekeyed) { await unlockVaultIfNeeded(); await invoke("secrets_rekey", { oldEncKey: kek, newEncKey: dek }); - } else if (sessionKey?.join(",") === dek.join(",")) { + } else if (sameKey(sessionKey, dek)) { return null; } setVaultKey(dek); @@ -850,6 +852,18 @@ async function moveVaultToDek(kek: number[], dek: number[]): Promise<(() => Prom }; } +/** Once the identity no longer rides on the vault key, a kek vault has no reason to stay there. */ +export async function settleVaultOnDek(): Promise { + const { kek, dek } = useVaultKeysStore.getState(); + const vaultKey = getVaultKey(); + if (!kek || !dek || !vaultKey || sameKey(vaultKey, dek)) return; + if ((await keychainGet("mode")) !== "server") return; + const { ensureIdentity } = await import("@/services/identity"); + const identity = await ensureIdentity().catch(() => null); + if (!identity?.stored) return; + if (await moveVaultToDek(kek, dek)) pushUnderNewVaultKey(); +} + // The server commits before minting tokens, so only a 4xx or its own answer proves a change did not land. async function passwordChangeMayHaveLanded(res: Response | null, newWrapped: string): Promise { if (res && res.status >= 400 && res.status < 500) return false; diff --git a/src/services/sync.ts b/src/services/sync.ts index 40c6a1861..324f24723 100644 --- a/src/services/sync.ts +++ b/src/services/sync.ts @@ -465,6 +465,13 @@ async function completeTeamLoginSetup(): Promise { // Register the public key even for users with no linked vaults, so it is // already there when they're added to a team. publishMyPublicKey still // refuses on an unproven vault key (#228). + try { + const { settleVaultOnDek } = await import("@/services/account"); + await settleVaultOnDek(); + } catch (e) { + logFailure("settle vault on dek")(e); + } + try { await publishMyPublicKey(); } catch { /* best-effort */ } From a2a8092b87c2fde697aa224ff6283b3ef19f1df8 Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 15:30:20 +0000 Subject: [PATCH 07/12] fix(vault): settle the vault at every sign-in and drop it on an account switch Settling ran only from the team login setup, which non-Pro accounts and paused devices never reach. It now runs first in the login syncs and on a paused launch, and gives up when the session changed while it waited. --- src/services/account.serverAuth.test.ts | 14 +++++++++++ src/services/account.ts | 1 + src/services/sync.realtime.test.ts | 31 +++++++++++++++++++++++++ src/services/sync.ts | 15 ++++++++---- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/services/account.serverAuth.test.ts b/src/services/account.serverAuth.test.ts index 3152ca246..a430bdcdf 100644 --- a/src/services/account.serverAuth.test.ts +++ b/src/services/account.serverAuth.test.ts @@ -1054,6 +1054,7 @@ test("a vault stays on the kek while the identity is not stored", async () => { h.ensureIdentity.mockResolvedValueOnce({ privateKey: "", publicKey: "", proven: true, stored: false }); await settleVaultOnDek(); expect(h.seq).not.toContain("secrets_rekey"); + expect(h.setVaultKey).not.toHaveBeenCalled(); }); test("an offline device keeps its vault and its unlock", async () => { @@ -1061,6 +1062,7 @@ test("an offline device keeps its vault and its unlock", async () => { h.ensureIdentity.mockRejectedValueOnce(new Error("offline")); await expect(settleVaultOnDek()).resolves.toBeUndefined(); expect(h.seq).not.toContain("secrets_rekey"); + expect(h.setVaultKey).not.toHaveBeenCalled(); }); test("a vault already on the dek is left alone without asking for the identity", async () => { @@ -1076,3 +1078,15 @@ test("a local account is never settled", async () => { await settleVaultOnDek(); expect(h.ensureIdentity).not.toHaveBeenCalled(); }); + +test("an account switch while the identity resolves leaves the new account's vault alone", async () => { + kekVaultWithDek(); + h.ensureIdentity.mockImplementationOnce(async () => { + h.sessionKey = [7, 7, 7]; + h.kek = [7, 7, 7]; + return { privateKey: btoa("id"), publicKey: "P", proven: true, stored: true }; + }); + await settleVaultOnDek(); + expect(h.seq).not.toContain("secrets_rekey"); + expect(h.setVaultKey).not.toHaveBeenCalled(); +}); diff --git a/src/services/account.ts b/src/services/account.ts index d69270f0e..306fb6f73 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -861,6 +861,7 @@ export async function settleVaultOnDek(): Promise { const { ensureIdentity } = await import("@/services/identity"); const identity = await ensureIdentity().catch(() => null); if (!identity?.stored) return; + if (getVaultKey() !== vaultKey || useVaultKeysStore.getState().kek !== kek) return; if (await moveVaultToDek(kek, dek)) pushUnderNewVaultKey(); } diff --git a/src/services/sync.realtime.test.ts b/src/services/sync.realtime.test.ts index 2cbadd282..7925e7be7 100644 --- a/src/services/sync.realtime.test.ts +++ b/src/services/sync.realtime.test.ts @@ -10,7 +10,9 @@ const h = vi.hoisted(() => ({ connect: undefined as unknown as Connect, events: new Map void>(), chunk: (() => {}) as (text: string) => void, + settle: vi.fn(async () => {}), })); +vi.mock("@/services/account", async (orig) => ({ ...(await orig()), settleVaultOnDek: h.settle })); vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke })); vi.mock("@tauri-apps/api/event", () => ({ @@ -39,6 +41,7 @@ import { syncInBackground, syncNow, syncOnLaunch, + syncOnLogin, } from "./sync"; import { useSubscriptionStore } from "@/stores/subscriptionStore"; import { useSyncPrefsStore } from "@/stores/syncPrefsStore"; @@ -80,6 +83,7 @@ beforeEach(() => { vi.useFakeTimers(); vi.spyOn(Math, "random").mockReturnValue(0.5); h.connects.length = 0; + h.settle.mockReset().mockResolvedValue(undefined); h.connect = unreachable; h.events.clear(); h.invoke.mockReset(); @@ -397,3 +401,30 @@ test("a stream whose catch-up sync keeps failing is retried at a growing interva expect(h.connects).toHaveLength(3); }); + +test("a login settles the vault before the Pro and teams branching", async () => { + useSubscriptionStore.setState({ isPro: false }); + await syncOnLogin(); + + expect(h.settle).toHaveBeenCalledTimes(1); +}); + +test("a paused launch still settles the vault, once", async () => { + setSyncPaused(true); + await syncOnLaunch(); + + expect(h.settle).toHaveBeenCalledTimes(1); + expect(deviceListFetches()).toBe(0); +}); + +test("a launch that syncs settles the vault once", async () => { + await syncOnLaunch(); + + expect(h.settle).toHaveBeenCalledTimes(1); +}); + +test("a failing settle does not fail the sync", async () => { + h.settle.mockRejectedValueOnce(new Error("rekey failed")); + await expect(syncOnLogin()).resolves.toBeUndefined(); + expect(deviceListFetches()).toBe(1); +}); diff --git a/src/services/sync.ts b/src/services/sync.ts index 324f24723..0742c1ee8 100644 --- a/src/services/sync.ts +++ b/src/services/sync.ts @@ -461,17 +461,19 @@ async function reloadAllStores(): Promise { ]); } -async function completeTeamLoginSetup(): Promise { - // Register the public key even for users with no linked vaults, so it is - // already there when they're added to a team. publishMyPublicKey still - // refuses on an unproven vault key (#228). +async function settleVault(): Promise { try { const { settleVaultOnDek } = await import("@/services/account"); await settleVaultOnDek(); } catch (e) { logFailure("settle vault on dek")(e); } +} +async function completeTeamLoginSetup(): Promise { + // Register the public key even for users with no linked vaults, so it is + // already there when they're added to a team. publishMyPublicKey still + // refuses on an unproven vault key (#228). try { await publishMyPublicKey(); } catch { /* best-effort */ } @@ -693,6 +695,7 @@ async function readDeviceSecrets(): Promise { * For new cloud accounts (linkToCloud), use syncOnLogin instead. */ export async function syncOnLoginReplace(): Promise { + await settleVault(); try { await unlockVaultIfNeeded(); @@ -737,6 +740,7 @@ export async function syncOnLoginReplace(): Promise { /** Pull on login to restore data from server, then push local state. */ export async function syncOnLogin(): Promise { _loginSyncOwed = false; + await settleVault(); try { await unlockVaultIfNeeded(); @@ -792,7 +796,8 @@ export function syncInBackground(forcePush = false): Promise { /** Launch sync. A paused device skips it; resuming runs it then. */ export async function syncOnLaunch(): Promise { - if (!isSyncPaused()) await syncOnLogin(); + if (isSyncPaused()) await settleVault(); + else await syncOnLogin(); } export function setSyncPaused(paused: boolean): void { From fe015144f012e9b08504f90061b9f06a11ccb19c Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 15:32:56 +0000 Subject: [PATCH 08/12] fix(account): link a local account onto the dek with a stored identity --- src/services/account.serverAuth.test.ts | 30 +++++++++++++++++++++++-- src/services/account.ts | 14 ++++++------ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/services/account.serverAuth.test.ts b/src/services/account.serverAuth.test.ts index a430bdcdf..dbb4f4a78 100644 --- a/src/services/account.serverAuth.test.ts +++ b/src/services/account.serverAuth.test.ts @@ -238,12 +238,38 @@ test("createServerAccount registers the identity its vault key derives, not the expect(h.sent["/auth/register"].public_key).toBe("DERIVED_FROM_1,1,1"); // the dek }); -test("linkToCloud registers the identity of the vault it keeps", async () => { +test("linkToCloud registers the identity a cloud-created account would have", async () => { h.store.master_password = "pw"; h.store.account_id = "acc"; h.http["/auth/register"] = ok(TOKENS); await linkToCloud("a@b.co", S); - expect(h.sent["/auth/register"].public_key).toBe("DERIVED_FROM_9,9,9"); + expect(h.sent["/auth/register"].public_key).toBe("DERIVED_FROM_1,1,1"); + expect(h.invoke).toHaveBeenCalledWith("wrap_user_secrets_cmd", { + kek: [9, 9, 9], + dek: [1, 1, 1], + x25519Private: Array.from(atob(btoa("legacy-x25519-private")), (c) => c.charCodeAt(0)), + }); +}); + +test("linkToCloud moves the vault it already has to the dek", async () => { + h.store.master_password = "pw"; + h.store.account_id = "acc"; + h.http["/auth/register"] = ok(TOKENS); + existingVaultOpenedBy([9, 9, 9]); + await linkToCloud("a@b.co", S); + expect(h.invoke).toHaveBeenCalledWith("secrets_rekey", { oldEncKey: [9, 9, 9], newEncKey: [1, 1, 1] }); + expect(h.setVaultKey).toHaveBeenLastCalledWith([1, 1, 1]); +}); + +test("a link whose rekey fails keeps the vault on the kek and still links", async () => { + h.store.master_password = "pw"; + h.store.account_id = "acc"; + h.http["/auth/register"] = ok(TOKENS); + existingVaultOpenedBy([9, 9, 9]); + h.rekeyError = new Error("disk full"); + await linkToCloud("a@b.co", S); + expect(h.store.mode).toBe("server"); + expect(h.fileKey).toEqual([9, 9, 9]); }); /** diff --git a/src/services/account.ts b/src/services/account.ts index 306fb6f73..f3b331b7b 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -800,23 +800,23 @@ export async function linkToCloud( if (!password) throw new Error(i18n.t("common.error.masterPasswordRequired")); const { auth_key, enc_key: kek } = await deriveKeys(password, accountId); - const secrets = await generateUserSecrets(); - const wrapped_user_secrets = await wrapUserSecrets(kek, secrets.dek, secrets.x25519_private); - // The kek, not the fresh dek: linking does not rekey the vault this account - // already has, so the session stays on the kek. - const { public_key } = await deriveX25519Keypair(kek); + const { dek } = await generateUserSecrets(); + const identity = await deriveX25519Keypair(dek); + const x25519Private = Array.from(base64ToBytes(identity.private_key)); + const wrapped_user_secrets = await wrapUserSecrets(kek, dek, x25519Private); const data = await registerOnServer({ serverUrl, email, accountId, authKey: auth_key, - publicKey: public_key, wrappedUserSecrets: wrapped_user_secrets, + publicKey: identity.public_key, wrappedUserSecrets: wrapped_user_secrets, }); - useVaultKeysStore.getState().set({ dek: secrets.dek, x25519Private: secrets.x25519_private, kek }); + useVaultKeysStore.getState().set({ dek, x25519Private, kek }); await persistServerSession({ serverUrl, email, jwt: data.jwt_token, refreshToken: data.refresh_token, }); await keychainSet("wrapped_user_secrets", wrapped_user_secrets); + await moveVaultToDek(kek, dek).catch((e) => console.warn("Linking kept the vault on the kek:", e)); reloadSubscription(); } From 672020569480b6efa0572ab7352d866e852c0347 Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 15:35:39 +0000 Subject: [PATCH 09/12] fix(account): keep the team identity across a password change --- src/services/account.leftovers.test.ts | 3 +++ src/services/account.serverAuth.test.ts | 24 ++++++++++++++++++++++++ src/services/account.ts | 5 +++++ 3 files changed, 32 insertions(+) diff --git a/src/services/account.leftovers.test.ts b/src/services/account.leftovers.test.ts index f23c3ab0f..c5766bb73 100644 --- a/src/services/account.leftovers.test.ts +++ b/src/services/account.leftovers.test.ts @@ -35,6 +35,9 @@ vi.mock("@/stores/subscriptionStore", () => ({ vi.mock("@/stores/vaultKeysStore", () => ({ useVaultKeysStore: { getState: () => ({ set: h.keysSet, clear: vi.fn(), dek: h.dek, x25519Private: h.x25519 }) }, })); +vi.mock("@/services/identity", () => ({ + ensureIdentity: vi.fn(async () => ({ privateKey: "", publicKey: "P", proven: false, stored: false })), +})); vi.mock("@/services/sync", () => ({ push: h.push, stopRealtimeSync: vi.fn() })); import { setMasterPassword, login, signInToCloud, changeMasterPassword } from "./account"; diff --git a/src/services/account.serverAuth.test.ts b/src/services/account.serverAuth.test.ts index dbb4f4a78..dfc1abb25 100644 --- a/src/services/account.serverAuth.test.ts +++ b/src/services/account.serverAuth.test.ts @@ -566,6 +566,30 @@ test("changeMasterPassword maps 401 to currentPasswordIncorrect", async () => { await expect(changeMasterPassword("old", "new")).rejects.toThrow("common.error.currentPasswordIncorrect"); }); +test("a password change wraps the proven identity under the new key", async () => { + cloudSessionOn([1, 1, 1]); + h.ensureIdentity.mockResolvedValueOnce({ privateKey: btoa("\x07\x07"), publicKey: "P", proven: true, stored: false }); + await changeMasterPassword("old", "new"); + expect(h.invoke).toHaveBeenCalledWith("wrap_user_secrets_cmd", { kek: [8, 8, 8], dek: [1, 1, 1], x25519Private: [7, 7] }); +}); + +test("an unproven identity keeps the stored key in the new wrap", async () => { + cloudSessionOn([1, 1, 1]); + h.ensureIdentity.mockResolvedValueOnce({ privateKey: btoa("x"), publicKey: "P", proven: false, stored: false }); + await changeMasterPassword("old", "new"); + expect(h.invoke).toHaveBeenCalledWith("wrap_user_secrets_cmd", { kek: [8, 8, 8], dek: [1, 1, 1], x25519Private: [2, 2, 2] }); +}); + +test("a session without cached keys adopts the server's before resolving the identity", async () => { + cloudSessionOn([1, 1, 1]); + h.dek = null; + h.x25519 = null; + h.http["/auth/me"] = ok({ wrapped_user_secrets: "W" }); + await changeMasterPassword("old", "new"); + expect(h.keysSet).toHaveBeenCalledWith({ dek: [1, 1, 1], x25519Private: [2, 2, 2], kek: [9, 9, 9] }); + expect(h.keysSet.mock.invocationCallOrder[0]).toBeLessThan(h.ensureIdentity.mock.invocationCallOrder[0]); +}); + test("changeMasterPassword rotates tokens and password on success", async () => { cloudSessionOn([1, 1, 1]); await changeMasterPassword("old", "new"); diff --git a/src/services/account.ts b/src/services/account.ts index f3b331b7b..0bdab72ac 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -900,8 +900,13 @@ export async function changeMasterPassword( const unwrapped = await unwrapUserSecrets(old_kek, me.wrapped_user_secrets); cachedDek = unwrapped.dek; cachedX25519 = unwrapped.x25519_private; + useVaultKeysStore.getState().set({ dek: cachedDek, x25519Private: cachedX25519, kek: old_kek }); } + const { ensureIdentity } = await import("@/services/identity"); + const identity = await ensureIdentity(); + if (identity.proven) cachedX25519 = Array.from(base64ToBytes(identity.privateKey)); + const { auth_key: new_auth_key, enc_key: new_kek } = await deriveKeys(newPassword, accountId); const new_wrapped_user_secrets = await wrapUserSecrets(new_kek, cachedDek, cachedX25519); From 96749a5725b8ba50a3e34afa32b8688f558dc212 Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 15:39:36 +0000 Subject: [PATCH 10/12] fix(account): reopen a vault stranded by a password change elsewhere The device that linked an account kept its vault on the old password's key, so a change made on another device or on the portal left it on the recovery screen. The password this device stored before the sign-in still derives that key. --- src/services/account.serverAuth.test.ts | 71 ++++++++++++++++++++++++- src/services/account.ts | 40 ++++++++++++-- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/services/account.serverAuth.test.ts b/src/services/account.serverAuth.test.ts index dfc1abb25..5e8deeeb7 100644 --- a/src/services/account.serverAuth.test.ts +++ b/src/services/account.serverAuth.test.ts @@ -150,7 +150,8 @@ const err = (status: number, body: unknown = {}) => ({ ok: false, status, body } beforeEach(() => { clearSessionRevoked(); h.kek = null; - h.ensureIdentity.mockClear(); + h.ensureIdentity.mockReset(); + h.ensureIdentity.mockImplementation(async () => ({ privateKey: btoa("id"), publicKey: "P", proven: true, stored: true })); for (const m of [h.invoke, h.appFetch, h.setVaultKey, h.wipeLocalConfig, h.load, h.keysSet]) m.mockReset(); h.readLocalSecrets.mockReset().mockResolvedValue({}); h.getVaultStatus.mockReset(); @@ -1140,3 +1141,71 @@ test("an account switch while the identity resolves leaves the new account's vau expect(h.seq).not.toContain("secrets_rekey"); expect(h.setVaultKey).not.toHaveBeenCalled(); }); + +// ─── stranded vault ────────────────────────────────────────────────────────── + +function strandedOnOldKek() { + h.store.account_id = "acc"; + h.store.mode = "server"; + h.store.email = "a@b.co"; + h.store.server_url = S; + h.http["/auth/login"] = ok({ ...TOKENS, wrapped_user_secrets: "W_NEW" }); + existingVaultOpenedBy([9, 9, 9]); +} + +test("sign-in after a change elsewhere reopens the vault with the password stored here", async () => { + strandedOnOldKek(); + h.store.master_password = "old"; + await login("new"); + expect(h.invoke).toHaveBeenCalledWith("secrets_rekey", { oldEncKey: [9, 9, 9], newEncKey: [1, 1, 1] }); + expect(h.setVaultKey).toHaveBeenLastCalledWith([1, 1, 1]); + expect(h.store.master_password).toBe("new"); + await vi.waitFor(() => expect(h.push).toHaveBeenCalled()); +}); + +test("the identity is looked up while the old key is still the vault key", async () => { + strandedOnOldKek(); + h.store.master_password = "old"; + let vaultKeyDuringLookup: number[] | null = null; + h.ensureIdentity.mockImplementationOnce(async () => { + vaultKeyDuringLookup = h.sessionKey; + return { privateKey: "", publicKey: "", proven: true, stored: true }; + }); + await login("new"); + expect(vaultKeyDuringLookup).toEqual([9, 9, 9]); +}); + +test("the server's wrapped secrets are cached before the lookup compares against them", async () => { + strandedOnOldKek(); + h.store.master_password = "old"; + let cachedDuringLookup: string | null | undefined; + h.ensureIdentity.mockImplementationOnce(async () => { + cachedDuringLookup = h.store.wrapped_user_secrets; + return { privateKey: "", publicKey: "", proven: true, stored: true }; + }); + await login("new"); + expect(cachedDuringLookup).toBe("W_NEW"); +}); + +test("a bound device reads the old sealed password to reopen the vault", async () => { + strandedOnOldKek(); + h.store.master_password_sealed = "S:old"; + await login("new"); + expect(h.setVaultKey).toHaveBeenLastCalledWith([1, 1, 1]); +}); + +test("a cancelled prompt on a bound device leaves today's unreadable vault", async () => { + strandedOnOldKek(); + h.store.master_password_sealed = "S:old"; + const route = h.invoke.getMockImplementation()!; + h.invoke.mockImplementation(async (cmd: string, args: Record) => + cmd === "vault_secret_get" ? { outcome: "cancelled", value: null } : route(cmd, args)); + await expect(login("new")).rejects.toThrow(VaultUnreadableError); + expect(h.seq).not.toContain("secrets_rekey"); +}); + +test("a device that stored the same password has nothing older to try", async () => { + strandedOnOldKek(); + h.store.master_password = "new"; + await expect(login("new")).rejects.toThrow(VaultUnreadableError); +}); diff --git a/src/services/account.ts b/src/services/account.ts index 0bdab72ac..b14c9878e 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -421,6 +421,7 @@ export async function login(password: string, email?: string, serverUrl?: string return; } + const readStoredPassword = await passwordStoredBefore(); const { auth_key, enc_key: kek } = await deriveKeys(password, accountId); // The local writes wait for the server: a sign-in left at the code step must not look like a local account. await serverLogin( @@ -436,12 +437,13 @@ export async function login(password: string, email?: string, serverUrl?: string if (data.wrapped_user_secrets) { const unwrapped = await unwrapUserSecrets(kek, data.wrapped_user_secrets); useVaultKeysStore.getState().set({ dek: unwrapped.dek, x25519Private: unwrapped.x25519_private, kek }); + await keychainSet("wrapped_user_secrets", data.wrapped_user_secrets); // This device's secrets.enc may predate the split and still be kek-encrypted. - const opens = await keyThatOpensVault(unwrapped.dek, kek); + const opens = (await keyThatOpensVault(unwrapped.dek, kek)) + ?? (await reopenWithStoredPassword(readStoredPassword, password, accountId, unwrapped.dek)); // Server login proved the password, so this is an unreadable file, not a bad one. if (!opens) throw new VaultUnreadableError(); setVaultKey(opens); - await keychainSet("wrapped_user_secrets", data.wrapped_user_secrets); } else { // Legacy account — trigger one-time migration await migrateToWrappedUserSecrets(password, accountId, kek, reauth.serverUrl, data.jwt_token); @@ -865,6 +867,38 @@ export async function settleVaultOnDek(): Promise { if (await moveVaultToDek(kek, dek)) pushUnderNewVaultKey(); } +/** The password stored before a sign-in replaces it; a sealed one is read, with its prompt, only if needed. */ +async function passwordStoredBefore(): Promise<() => Promise> { + const state = await secretState().catch(() => "none" as const); + if (state === "sealed") { + return async () => { + const read = await readSecret(i18n.t("layout.appLock.sealReason")); + return read.outcome === "ok" ? read.value : null; + }; + } + const plain = state === "plain" ? await readPlainSecret().catch(() => null) : null; + return async () => plain; +} + +/** A change made elsewhere leaves this vault on the old kek, which the password stored here still reaches. */ +async function reopenWithStoredPassword( + readStored: () => Promise, + typed: string, + accountId: string, + dek: number[], +): Promise { + const stored = await readStored(); + if (!stored || stored === typed) return null; + const { enc_key: oldKek } = await deriveKeys(stored, accountId); + if (!(await keyThatOpensVault(oldKek))) return null; + setVaultKey(oldKek); + const { ensureIdentity } = await import("@/services/identity"); + await ensureIdentity().catch(() => null); + await moveVaultToDek(oldKek, dek); + pushUnderNewVaultKey(); + return dek; +} + // The server commits before minting tokens, so only a 4xx or its own answer proves a change did not land. async function passwordChangeMayHaveLanded(res: Response | null, newWrapped: string): Promise { if (res && res.status >= 400 && res.status < 500) return false; @@ -911,7 +945,7 @@ export async function changeMasterPassword( const new_wrapped_user_secrets = await wrapUserSecrets(new_kek, cachedDek, cachedX25519); const undoMove = await moveVaultToDek(old_kek, cachedDek); - // The team identity derives from the vault key, so a change that did not land must not keep the move. + // A change that did not land must leave the vault on the key the current password still reaches. const undoMoveUnlessLanded = async (res: Response | null) => { if (!undoMove || (await passwordChangeMayHaveLanded(res, new_wrapped_user_secrets))) return; await undoMove().catch((e) => console.warn("Putting the vault back on its old key failed:", e)); From 91be81cd31bd09ea08df5933a3f1f1acf1576b7d Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 16:06:14 +0000 Subject: [PATCH 11/12] fix(identity): harden recovery, respect sync pause, dek identity when unpublished - A stranded-device recovery that fails at any step (derive, read error, rekey) restores the session key it found and ends in VaultUnreadableError. - pushUnderNewVaultKey skips the upload while sync is paused; resuming pushes. - An unpublished roster resolves to HKDF(DEK), stored unless already held. - A stored private key the backend rejects is passed over, not fatal. - The identity store write skips the in-memory keys after an account switch during the keychain write. - base64ToByteArray moves to utils/base64 and replaces the inline copies; the identity import in account.ts goes through one helper. --- src/services/account.leftovers.test.ts | 2 +- src/services/account.logout.test.ts | 2 +- src/services/account.serverAuth.test.ts | 63 +++++++++++++++++++++++-- src/services/account.ts | 46 +++++++++++------- src/services/identity.test.ts | 45 ++++++++++++++---- src/services/identity.ts | 14 +++--- src/services/teamVaultSyncCore.ts | 10 +--- src/utils/base64.ts | 5 ++ 8 files changed, 140 insertions(+), 47 deletions(-) diff --git a/src/services/account.leftovers.test.ts b/src/services/account.leftovers.test.ts index c5766bb73..2be816cd2 100644 --- a/src/services/account.leftovers.test.ts +++ b/src/services/account.leftovers.test.ts @@ -38,7 +38,7 @@ vi.mock("@/stores/vaultKeysStore", () => ({ vi.mock("@/services/identity", () => ({ ensureIdentity: vi.fn(async () => ({ privateKey: "", publicKey: "P", proven: false, stored: false })), })); -vi.mock("@/services/sync", () => ({ push: h.push, stopRealtimeSync: vi.fn() })); +vi.mock("@/services/sync", () => ({ push: h.push, isSyncPaused: () => false, stopRealtimeSync: vi.fn() })); import { setMasterPassword, login, signInToCloud, changeMasterPassword } from "./account"; diff --git a/src/services/account.logout.test.ts b/src/services/account.logout.test.ts index 4ab2997b4..83713127f 100644 --- a/src/services/account.logout.test.ts +++ b/src/services/account.logout.test.ts @@ -25,7 +25,7 @@ vi.mock("@/stores/subscriptionStore", () => ({ vi.mock("@/stores/vaultKeysStore", () => ({ useVaultKeysStore: { getState: () => ({ set: vi.fn(), clear: vi.fn(), dek: null, x25519Private: null }) }, })); -vi.mock("@/services/sync", () => ({ push: vi.fn(async () => undefined), stopRealtimeSync: vi.fn() })); +vi.mock("@/services/sync", () => ({ push: vi.fn(async () => undefined), isSyncPaused: () => false, stopRealtimeSync: vi.fn() })); vi.mock("@/services/teamDataManager", () => ({ onSessionEnd: vi.fn() })); vi.mock("@/services/savedAccounts", () => ({ removeSavedAccount: h.removeSavedAccount })); diff --git a/src/services/account.serverAuth.test.ts b/src/services/account.serverAuth.test.ts index 5e8deeeb7..567143042 100644 --- a/src/services/account.serverAuth.test.ts +++ b/src/services/account.serverAuth.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { routeVaultSecret } from "@/test/vaultSecretRoute"; import { test, expect, vi, beforeEach } from "vitest"; +import type { Identity } from "./identity"; const h = vi.hoisted(() => ({ invoke: vi.fn(), @@ -27,7 +28,9 @@ const h = vi.hoisted(() => ({ dek: null as number[] | null, x25519: null as number[] | null, kek: null as number[] | null, - ensureIdentity: vi.fn(async () => ({ privateKey: btoa("id"), publicKey: "P", proven: true, stored: true })), + ensureIdentity: vi.fn<() => Promise>(), + syncPaused: false, + isSyncPaused: vi.fn((): boolean => h.syncPaused), emailVerified: false, seq: [] as string[], /** Request bodies by endpoint path, so a test can assert what was sent. */ @@ -37,7 +40,7 @@ const h = vi.hoisted(() => ({ vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke })); vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); vi.mock("@/services/http", () => ({ appFetch: h.appFetch, isAbortError: () => false })); -vi.mock("@/services/sync", () => ({ push: h.push })); +vi.mock("@/services/sync", () => ({ push: h.push, isSyncPaused: h.isSyncPaused })); vi.mock("./vault", () => ({ setVaultKey: (key: number[]) => { h.sessionKey = key; @@ -83,6 +86,7 @@ import { DEFAULT_SERVER_URL, lastServerUrl } from "@/utils/serverInstance"; import { GLOBAL_PROXY_PASSWORD_KEY } from "./teamVaultSecretKeys"; const S = "https://srv"; +const STORED_IDENTITY: Identity = { privateKey: btoa("id"), publicKey: "P", proven: true, stored: true }; const TOKENS = { jwt_token: "JWT", refresh_token: "RT" }; function routeInvoke() { @@ -151,7 +155,9 @@ beforeEach(() => { clearSessionRevoked(); h.kek = null; h.ensureIdentity.mockReset(); - h.ensureIdentity.mockImplementation(async () => ({ privateKey: btoa("id"), publicKey: "P", proven: true, stored: true })); + h.ensureIdentity.mockResolvedValue(STORED_IDENTITY); + h.syncPaused = false; + h.isSyncPaused.mockClear(); for (const m of [h.invoke, h.appFetch, h.setVaultKey, h.wipeLocalConfig, h.load, h.keysSet]) m.mockReset(); h.readLocalSecrets.mockReset().mockResolvedValue({}); h.getVaultStatus.mockReset(); @@ -591,6 +597,16 @@ test("a session without cached keys adopts the server's before resolving the ide expect(h.keysSet.mock.invocationCallOrder[0]).toBeLessThan(h.ensureIdentity.mock.invocationCallOrder[0]); }); +test("an identity that cannot be resolved fails the change before anything is touched", async () => { + cloudSessionOn([9, 9, 9]); + existingVaultOpenedBy([9, 9, 9]); + h.ensureIdentity.mockRejectedValueOnce(new Error("offline")); + await expect(changeMasterPassword("old", "new")).rejects.toThrow("offline"); + expect(h.seq).not.toContain("secrets_rekey"); + expect(step("/auth/password")).toBe(-1); + expect(h.setVaultKey).not.toHaveBeenCalled(); +}); + test("changeMasterPassword rotates tokens and password on success", async () => { cloudSessionOn([1, 1, 1]); await changeMasterPassword("old", "new"); @@ -1100,6 +1116,15 @@ test("a kek vault moves to the dek once the identity is stored", async () => { await vi.waitFor(() => expect(h.push).toHaveBeenCalled()); }); +test("a paused device moves its vault to the dek without uploading", async () => { + kekVaultWithDek(); + h.syncPaused = true; + await settleVaultOnDek(); + expect(h.invoke).toHaveBeenCalledWith("secrets_rekey", { oldEncKey: [9, 9, 9], newEncKey: [1, 1, 1] }); + await vi.waitFor(() => expect(h.isSyncPaused).toHaveBeenCalled()); + expect(h.push).not.toHaveBeenCalled(); +}); + test("a vault stays on the kek while the identity is not stored", async () => { kekVaultWithDek(); h.ensureIdentity.mockResolvedValueOnce({ privateKey: "", publicKey: "", proven: true, stored: false }); @@ -1135,7 +1160,7 @@ test("an account switch while the identity resolves leaves the new account's vau h.ensureIdentity.mockImplementationOnce(async () => { h.sessionKey = [7, 7, 7]; h.kek = [7, 7, 7]; - return { privateKey: btoa("id"), publicKey: "P", proven: true, stored: true }; + return STORED_IDENTITY; }); await settleVaultOnDek(); expect(h.seq).not.toContain("secrets_rekey"); @@ -1209,3 +1234,33 @@ test("a device that stored the same password has nothing older to try", async () h.store.master_password = "new"; await expect(login("new")).rejects.toThrow(VaultUnreadableError); }); + +test.each([ + ["its rekey fails", () => { + h.rekeyError = new Error("disk full"); + }], + ["the file cannot be read", () => { + const fits = h.verifyVaultKey.getMockImplementation()!; + h.verifyVaultKey.mockImplementation(async (key: number[]) => { + if (String(key) === "9,9,9") throw new Error("EBUSY"); + return fits(key); + }); + }], +])("a recovery where %s is an unreadable vault, on the key the sign-in installed", async (_name, fail) => { + strandedOnOldKek(); + h.store.master_password = "old"; + fail(); + await expect(login("new")).rejects.toThrow(VaultUnreadableError); + expect(h.sessionKey).toEqual([8, 8, 8]); + expect(h.push).not.toHaveBeenCalled(); +}); + +test("a stored password that opens nothing either is an unreadable vault", async () => { + strandedOnOldKek(); + // "zzz" derives [9, 9, 9]; this file is on a key neither password reaches. + existingVaultOpenedBy([7, 7, 7]); + h.store.master_password = "zzz"; + await expect(login("new")).rejects.toThrow(VaultUnreadableError); + expect(h.seq).not.toContain("secrets_rekey"); + expect(h.sessionKey).toEqual([8, 8, 8]); +}); diff --git a/src/services/account.ts b/src/services/account.ts index b14c9878e..ab0e0fa5d 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -10,7 +10,7 @@ import { appFetch, isAbortError } from "@/services/http"; import { VaultUnreadableError } from "./vaultErrors"; import { clearSecret, readPlainSecret, readSecret, rememberPassword, replacePassword, secretState, storePassword } from "./vaultSecret"; import { rememberServer } from "@/utils/serverInstance"; -import { base64ToBytes, hexToBytes } from "@/utils/base64"; +import { base64ToByteArray, hexToBytes } from "@/utils/base64"; import { EmailUndeliverableError, isEmailUndeliverable } from "@/utils/emailVerification"; import type { SavedAccount } from "./savedAccounts"; import { clearSessionRevoked, refreshAccessToken } from "./authTokens"; @@ -804,7 +804,7 @@ export async function linkToCloud( const { auth_key, enc_key: kek } = await deriveKeys(password, accountId); const { dek } = await generateUserSecrets(); const identity = await deriveX25519Keypair(dek); - const x25519Private = Array.from(base64ToBytes(identity.private_key)); + const x25519Private = base64ToByteArray(identity.private_key); const wrapped_user_secrets = await wrapUserSecrets(kek, dek, x25519Private); const data = await registerOnServer({ @@ -824,9 +824,15 @@ export async function linkToCloud( // ─── New account management features ───────────────────────────────────────── -/** Upload this device's blob under the vault key just installed; never throws. */ +/** Upload this device's blob under the vault key just installed; never throws. A paused device pushes on resume. */ function pushUnderNewVaultKey(): void { - import("@/services/sync").then(({ push }) => push()).catch(() => {}); + import("@/services/sync") + .then(({ push, isSyncPaused }) => (isSyncPaused() ? undefined : push())) + .catch(() => {}); +} + +async function ensureIdentity() { + return (await import("@/services/identity")).ensureIdentity(); } /** @@ -860,7 +866,6 @@ export async function settleVaultOnDek(): Promise { const vaultKey = getVaultKey(); if (!kek || !dek || !vaultKey || sameKey(vaultKey, dek)) return; if ((await keychainGet("mode")) !== "server") return; - const { ensureIdentity } = await import("@/services/identity"); const identity = await ensureIdentity().catch(() => null); if (!identity?.stored) return; if (getVaultKey() !== vaultKey || useVaultKeysStore.getState().kek !== kek) return; @@ -887,16 +892,22 @@ async function reopenWithStoredPassword( accountId: string, dek: number[], ): Promise { - const stored = await readStored(); - if (!stored || stored === typed) return null; - const { enc_key: oldKek } = await deriveKeys(stored, accountId); - if (!(await keyThatOpensVault(oldKek))) return null; - setVaultKey(oldKek); - const { ensureIdentity } = await import("@/services/identity"); - await ensureIdentity().catch(() => null); - await moveVaultToDek(oldKek, dek); - pushUnderNewVaultKey(); - return dek; + const sessionKey = getVaultKey(); + try { + const stored = await readStored(); + if (!stored || stored === typed) return null; + const { enc_key: oldKek } = await deriveKeys(stored, accountId); + if (!(await keyThatOpensVault(oldKek))) return null; + setVaultKey(oldKek); + await ensureIdentity().catch(() => null); + await moveVaultToDek(oldKek, dek); + pushUnderNewVaultKey(); + return dek; + } catch (e) { + console.warn("Reopening the vault with the stored password failed:", e); + if (sessionKey) setVaultKey(sessionKey); + return null; + } } // The server commits before minting tokens, so only a 4xx or its own answer proves a change did not land. @@ -937,9 +948,8 @@ export async function changeMasterPassword( useVaultKeysStore.getState().set({ dek: cachedDek, x25519Private: cachedX25519, kek: old_kek }); } - const { ensureIdentity } = await import("@/services/identity"); const identity = await ensureIdentity(); - if (identity.proven) cachedX25519 = Array.from(base64ToBytes(identity.privateKey)); + if (identity.proven) cachedX25519 = base64ToByteArray(identity.privateKey); const { auth_key: new_auth_key, enc_key: new_kek } = await deriveKeys(newPassword, accountId); const new_wrapped_user_secrets = await wrapUserSecrets(new_kek, cachedDek, cachedX25519); @@ -1013,7 +1023,7 @@ async function migrateToWrappedUserSecrets( // Derive existing deterministic X25519 keypair from legacy enc_key (= kek) const { private_key: legacyX25519PrivateB64 } = await deriveX25519Keypair(kek); - const legacyX25519Private = Array.from(base64ToBytes(legacyX25519PrivateB64)); + const legacyX25519Private = base64ToByteArray(legacyX25519PrivateB64); const secrets = await generateUserSecrets(); const dek = secrets.dek; diff --git a/src/services/identity.test.ts b/src/services/identity.test.ts index 90a876a08..2a439111e 100644 --- a/src/services/identity.test.ts +++ b/src/services/identity.test.ts @@ -216,20 +216,49 @@ test("no stored wrapped secrets means no write", async () => { expect(h.fetch).not.toHaveBeenCalled(); }); -test("an unpublished roster keeps the stored identity without a write", async () => { - cloudSession(); - await expect(ensureIdentity()).resolves.toMatchObject({ publicKey: `PUB_OF_${STORED}`, proven: true, stored: true }); +const dekPrivate = () => bytes(btoa(`priv-${DEK}`)); + +test("an unpublished roster and a stored dek identity need no write", async () => { + useVaultKeysStore.getState().set({ kek: KEK, dek: DEK, x25519Private: dekPrivate() }); + await expect(ensureIdentity()).resolves.toMatchObject({ publicKey: derivedPub(DEK), proven: true, stored: true }); expect(h.fetch).not.toHaveBeenCalled(); }); -test("an unpublished roster and no stored key store the vault key identity", async () => { - cloudSession(); - useVaultKeysStore.setState({ x25519Private: null }); - await expect(ensureIdentity()).resolves.toMatchObject({ publicKey: derivedPub([5, 5, 5]), proven: true, stored: true }); +test.each([ + ["another stored key", STORED], + ["no stored key", null], +])("an unpublished roster with %s stores the dek identity", async (_name, x25519Private) => { + useVaultKeysStore.setState({ kek: KEK, dek: DEK, x25519Private }); + await expect(ensureIdentity()).resolves.toMatchObject({ publicKey: derivedPub(DEK), proven: true, stored: true }); expect(sentBody()).toEqual({ - wrapped_user_secrets: `WRAPPED(${bytes(btoa(`priv-${[5, 5, 5]}`))})`, + wrapped_user_secrets: `WRAPPED(${dekPrivate()})`, expected_wrapped_user_secrets: "OLD_WRAPPED", }); + expect(useVaultKeysStore.getState().x25519Private).toEqual(dekPrivate()); +}); + +test("a stored key that is not a valid private key is passed over", async () => { + cloudSession(); + h.published = derivedPub(DEK); + const impl = h.invoke.getMockImplementation()!; + h.invoke.mockImplementation(async (cmd: string, args: Record) => { + if (cmd === "x25519_public_from_private") throw new Error("not 32 bytes"); + return impl(cmd, args); + }); + await expect(ensureIdentity()).resolves.toMatchObject({ publicKey: derivedPub(DEK), proven: true, stored: true }); +}); + +test("an account switch during the keychain write leaves the new session's keys alone", async () => { + cloudSession(); + h.published = derivedPub(DEK); + const impl = h.invoke.getMockImplementation()!; + h.invoke.mockImplementation(async (cmd: string, args: Record) => { + const r = await impl(cmd, args); + if (cmd === "keychain_set") switchAccount(); + return r; + }); + await expect(ensureIdentity()).resolves.toMatchObject({ proven: true, stored: true }); + expect(useVaultKeysStore.getState().x25519Private).toEqual([3, 3, 3]); }); test("a wrap failure still resolves a proven identity", async () => { diff --git a/src/services/identity.ts b/src/services/identity.ts index 97eefa2ee..216e2b8fc 100644 --- a/src/services/identity.ts +++ b/src/services/identity.ts @@ -8,7 +8,7 @@ import * as teamService from "@/services/teamService"; import { fetchAuthJson } from "@/services/authFetch"; import { getServerUrl } from "@/services/authTokens"; import { serverSupportsWrappedSecretsCas } from "@/services/serverCapabilities"; -import { base64ToBytes, bytesToBase64 } from "@/utils/base64"; +import { base64ToByteArray, bytesToBase64 } from "@/utils/base64"; export interface Identity { privateKey: string; @@ -51,7 +51,7 @@ async function store( if (!expected || !stillCurrent()) return false; const serverUrl = await getServerUrl(); if (!serverUrl || !(await serverSupportsWrappedSecretsCas())) return false; - const x25519Private = Array.from(base64ToBytes(identity.privateKey)); + const x25519Private = base64ToByteArray(identity.privateKey); const next = await wrapUserSecrets(keys.kek, keys.dek, x25519Private); const res = await fetchAuthJson(`${serverUrl}/v1/auth/wrapped-user-secrets`, { method: "PUT", @@ -67,7 +67,7 @@ async function store( } if (!stillCurrent()) return false; await invoke("keychain_set", { key: "wrapped_user_secrets", value: next }); - useVaultKeysStore.getState().set({ ...keys, x25519Private }); + if (stillCurrent()) useVaultKeysStore.getState().set({ ...keys, x25519Private }); return true; } @@ -89,10 +89,12 @@ async function resolve( const published = (await teamService.getUserPublicKey(myUserId))?.public_key ?? null; if (!stillCurrent()) return unproven(); - const held = x25519Private ? await fromPrivate(x25519Private) : null; - if (held && (!published || held.publicKey === published)) return { ...held, proven: true, stored: true }; + const held = x25519Private ? await fromPrivate(x25519Private).catch(() => null) : null; + if (held && held.publicKey === published) return { ...held, proven: true, stored: true }; const candidates = await Promise.all([vaultKey, kek, dek].map(derive)); - const match = published ? candidates.find((c) => c.publicKey === published) : candidates[0]; + const fromDek = candidates[2]; + if (!published && held?.privateKey === fromDek.privateKey) return { ...fromDek, proven: true, stored: true }; + const match = published ? candidates.find((c) => c.publicKey === published) : fromDek; if (!match) { if (stillCurrent()) useVaultKeysStore.getState().markIdentityUnproven(); return unproven(); diff --git a/src/services/teamVaultSyncCore.ts b/src/services/teamVaultSyncCore.ts index 36f168c95..3005ace82 100644 --- a/src/services/teamVaultSyncCore.ts +++ b/src/services/teamVaultSyncCore.ts @@ -3,9 +3,7 @@ * blob's `files` map build/parse. No IO, no crypto — safe to unit test directly. */ -import { base64ToBytes } from "@/utils/base64"; - -export { bytesToBase64 } from "@/utils/base64"; +export { bytesToBase64, base64ToByteArray } from "@/utils/base64"; export interface TeamVaultSlices { connections: unknown[]; @@ -17,12 +15,6 @@ export interface TeamVaultSlices { portForwardingRules: unknown[]; } -// Tauri commands take `Vec` as a JSON number array, which a Uint8Array -// does not serialize to. -export function base64ToByteArray(b64: string): number[] { - return Array.from(base64ToBytes(b64)); -} - export function parseTeamVaultFile(json: string | undefined): T[] { try { return JSON.parse(json ?? "[]"); diff --git a/src/utils/base64.ts b/src/utils/base64.ts index 88234588f..80183289d 100644 --- a/src/utils/base64.ts +++ b/src/utils/base64.ts @@ -16,6 +16,11 @@ export function base64ToBytes(b64: string): Uint8Array { return bytes; } +// Tauri commands take `Vec` as a JSON number array, which a Uint8Array does not serialize to. +export function base64ToByteArray(b64: string): number[] { + return Array.from(base64ToBytes(b64)); +} + export function hexToBytes(hex: string): number[] { const bytes: number[] = []; for (let i = 0; i < hex.length; i += 2) bytes.push(parseInt(hex.slice(i, i + 2), 16)); From edec7cb3ad611824e46b525577b2a0938164b109 Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 14 Oct 2026 17:37:46 +0000 Subject: [PATCH 12/12] fix(account): seal the new password after a change made elsewhere A bound device kept the old password sealed after signing in with one changed on another device: rememberPassword leaves a sealed secret alone, so the next biometric unlock failed and the device-verified password change read a stale password. When the typed password does not unwrap this device's cached secrets, it is sealed anew once the vault is open. --- src/services/account.serverAuth.test.ts | 25 ++++++++++++++++++++++++- src/services/account.ts | 15 +++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/services/account.serverAuth.test.ts b/src/services/account.serverAuth.test.ts index 567143042..3bca9a845 100644 --- a/src/services/account.serverAuth.test.ts +++ b/src/services/account.serverAuth.test.ts @@ -118,7 +118,9 @@ function routeInvoke() { return { dek: [1, 1, 1], x25519_private: [2, 2, 2], x25519_public: "RANDOM_PUB" }; case "wrap_user_secrets_cmd": return "WRAPPED_B64"; + // "W_OLD" stands for secrets wrapped under the old password's kek. case "unwrap_user_secrets_cmd": + if (args.wrappedB64 === "W_OLD" && String(args.kek) !== "9,9,9") throw new Error("Decryption failed"); return { dek: [1, 1, 1], x25519_private: [2, 2, 2] }; case "get_machine_fingerprint": return "FP"; @@ -1212,11 +1214,32 @@ test("the server's wrapped secrets are cached before the lookup compares against expect(cachedDuringLookup).toBe("W_NEW"); }); -test("a bound device reads the old sealed password to reopen the vault", async () => { +test("a bound device reads the old sealed password to reopen the vault, then seals the new one", async () => { strandedOnOldKek(); h.store.master_password_sealed = "S:old"; + h.store.wrapped_user_secrets = "W_OLD"; await login("new"); expect(h.setVaultKey).toHaveBeenLastCalledWith([1, 1, 1]); + expect(h.store.master_password_sealed).toBe("S:new"); +}); + +test("a bound device signing in after a change elsewhere seals the new password", async () => { + strandedOnOldKek(); + existingVaultOpenedBy([1, 1, 1]); + h.store.master_password_sealed = "S:old"; + h.store.wrapped_user_secrets = "W_OLD"; + await login("new"); + expect(h.store.master_password_sealed).toBe("S:new"); +}); + +test("a bound device signing in with the password it already holds is not asked to seal again", async () => { + strandedOnOldKek(); + existingVaultOpenedBy([1, 1, 1]); + h.store.master_password_sealed = "S:old"; + h.store.wrapped_user_secrets = "W_OLD"; + await login("old"); + expect(h.invoke).not.toHaveBeenCalledWith("vault_secret_set", expect.anything()); + expect(h.store.master_password_sealed).toBe("S:old"); }); test("a cancelled prompt on a bound device leaves today's unreadable vault", async () => { diff --git a/src/services/account.ts b/src/services/account.ts index ab0e0fa5d..4e36e7e9b 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -423,6 +423,7 @@ export async function login(password: string, email?: string, serverUrl?: string const readStoredPassword = await passwordStoredBefore(); const { auth_key, enc_key: kek } = await deriveKeys(password, accountId); + const knownHere = await cachedSecretsUnwrapWith(kek); // The local writes wait for the server: a sign-in left at the code step must not look like a local account. await serverLogin( reauth.serverUrl, accountId, auth_key, @@ -444,6 +445,8 @@ export async function login(password: string, email?: string, serverUrl?: string // Server login proved the password, so this is an unreadable file, not a bad one. if (!opens) throw new VaultUnreadableError(); setVaultKey(opens); + // After the reopen: it may still need the old sealed password. + if (!knownHere) await resealChangedPassword(password); } else { // Legacy account — trigger one-time migration await migrateToWrappedUserSecrets(password, accountId, kek, reauth.serverUrl, data.jwt_token); @@ -872,6 +875,18 @@ export async function settleVaultOnDek(): Promise { if (await moveVaultToDek(kek, dek)) pushUnderNewVaultKey(); } +/** Whether this device already knew this password: its cached secrets are wrapped under its kek. */ +async function cachedSecretsUnwrapWith(kek: number[]): Promise { + const cached = await keychainGet("wrapped_user_secrets"); + return !!cached && (await unwrapUserSecrets(kek, cached).then(() => true, () => false)); +} + +/** `rememberPassword` keeps a sealed secret, so a password changed elsewhere must be sealed anew. */ +async function resealChangedPassword(password: string): Promise { + if ((await secretState().catch(() => "none")) !== "sealed") return; + await replacePassword(password, i18n.t("layout.appLock.sealReason")); +} + /** The password stored before a sign-in replaces it; a sealed one is read, with its prompt, only if needed. */ async function passwordStoredBefore(): Promise<() => Promise> { const state = await secretState().catch(() => "none" as const);