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, diff --git a/src/services/account.leftovers.test.ts b/src/services/account.leftovers.test.ts index f23c3ab0f..2be816cd2 100644 --- a/src/services/account.leftovers.test.ts +++ b/src/services/account.leftovers.test.ts @@ -35,7 +35,10 @@ 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/sync", () => ({ push: h.push, stopRealtimeSync: vi.fn() })); +vi.mock("@/services/identity", () => ({ + ensureIdentity: vi.fn(async () => ({ privateKey: "", publicKey: "P", proven: false, stored: false })), +})); +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 f362ed8fb..3bca9a845 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(), @@ -26,6 +27,10 @@ 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<() => 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. */ @@ -35,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; @@ -53,8 +58,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 +71,7 @@ import { signInToCloud, linkToCloud, changeMasterPassword, + settleVaultOnDek, changeEmail, refreshSession, refreshVerificationState, @@ -79,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() { @@ -110,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"; @@ -145,6 +155,11 @@ const err = (status: number, body: unknown = {}) => ({ ok: false, status, body } beforeEach(() => { clearSessionRevoked(); + h.kek = null; + h.ensureIdentity.mockReset(); + 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(); @@ -232,12 +247,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]); }); /** @@ -534,6 +575,40 @@ 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("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"); @@ -1026,3 +1101,189 @@ 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 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 }); + 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 () => { + kekVaultWithDek(); + 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 () => { + 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(); +}); + +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 STORED_IDENTITY; + }); + await settleVaultOnDek(); + 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, 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 () => { + 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); +}); + +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 435feeb8e..4e36e7e9b 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"; @@ -9,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"; @@ -36,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); } @@ -48,19 +52,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; @@ -296,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 ─────────────────────────────────────────────────────── @@ -427,7 +421,9 @@ 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); + 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, @@ -442,12 +438,15 @@ 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); + // 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); @@ -806,31 +805,37 @@ 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 = base64ToByteArray(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(); } // ─── 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(); } /** @@ -845,7 +850,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); @@ -858,6 +863,68 @@ 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 identity = await ensureIdentity().catch(() => null); + if (!identity?.stored) return; + if (getVaultKey() !== vaultKey || useVaultKeysStore.getState().kek !== kek) return; + 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); + 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 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. async function passwordChangeMayHaveLanded(res: Response | null, newWrapped: string): Promise { if (res && res.status >= 400 && res.status < 500) return false; @@ -893,13 +960,17 @@ 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 identity = await ensureIdentity(); + 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); 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)); @@ -967,7 +1038,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/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 new file mode 100644 index 000000000..2a439111e --- /dev/null +++ b/src/services/identity.test.ts @@ -0,0 +1,274 @@ +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[], + rosterGate: undefined as Promise | undefined, +})); +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"); + await h.rosterGate; + 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.rosterGate = undefined; + 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("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"); +}); + +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(); +}); + +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.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(${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 () => { + 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 new file mode 100644 index 000000000..216e2b8fc --- /dev/null +++ b/src/services/identity.ts @@ -0,0 +1,124 @@ +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"; +import { fetchAuthJson } from "@/services/authFetch"; +import { getServerUrl } from "@/services/authTokens"; +import { serverSupportsWrappedSecretsCas } from "@/services/serverCapabilities"; +import { base64ToByteArray, 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 deriveX25519Keypair(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, + stillCurrent: () => boolean, +): Promise { + if (!expected || !stillCurrent()) return false; + const serverUrl = await getServerUrl(); + if (!serverUrl || !(await serverSupportsWrappedSecretsCas())) return false; + 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", + body: JSON.stringify({ wrapped_user_secrets: next, expected_wrapped_user_secrets: expected }), + }).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 }); + if (stillCurrent()) useVaultKeysStore.getState().set({ ...keys, x25519Private }); + return true; +} + +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) { + flags.noUser = true; + return unproven(); + } + const published = (await teamService.getUserPublicKey(myUserId))?.public_key ?? null; + if (!stillCurrent()) return unproven(); + + 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 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(); + } + 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. */ +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 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; +} 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 }; } /** 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; +} 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 40c6a1861..0742c1ee8 100644 --- a/src/services/sync.ts +++ b/src/services/sync.ts @@ -461,6 +461,15 @@ async function reloadAllStores(): Promise { ]); } +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 @@ -686,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(); @@ -730,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(); @@ -785,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 { 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));