From 0024631ef35016d190aa1de24d54f533caa31ce7 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 10 Jun 2026 11:04:49 -0700 Subject: [PATCH 1/2] Red: test TOTP preference when account has both passkey and TOTP When an account has both passkeys and TOTP enrolled, the Ente server returns passkeySessionID + twoFactorSessionIDV2 (deliberately not the V1 twoFactorSessionID field, so old clients keep using passkeys). quak only checked the V1 field, saw the passkey session, and threw 'Passkey authentication is not supported'. A CLI cannot do WebAuthn, so it must take the TOTP path via the V2 session ID. --- test/auth/login.test.ts | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/test/auth/login.test.ts b/test/auth/login.test.ts index 0519a22..e1bf4a6 100644 --- a/test/auth/login.test.ts +++ b/test/auth/login.test.ts @@ -145,7 +145,7 @@ const buildServerFixture = async (password: string) => { */ const buildMockFetch = ( fixture: Awaited>, - opts?: { requireTOTP?: boolean }, + opts?: { requireTOTP?: boolean; requirePasskeyAndTOTP?: boolean }, ) => { let srpServer: SrpServer; const sessionID = "test-session-id"; @@ -213,6 +213,25 @@ const buildMockFetch = ( ); } + if (opts?.requirePasskeyAndTOTP) { + // When the account has BOTH passkeys and TOTP enabled, the + // server sets passkeySessionID + twoFactorSessionIDV2 (not + // twoFactorSessionID -- that's deliberate, so old clients + // that only know the V1 field keep using the passkey flow). + return new Response( + JSON.stringify({ + srpM2: M2.toString("base64"), + passkeySessionID: "passkey-session-123", + accountsUrl: "https://accounts.ente.io", + twoFactorSessionIDV2: "totp-session-v2-456", + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + } + return new Response( JSON.stringify({ srpM2: M2.toString("base64"), @@ -305,6 +324,26 @@ describe("beginLogin via SRP", () => { } }); + it("prefers TOTP over passkey when the account has both", async () => { + // Accounts with both passkeys and TOTP get passkeySessionID + + // twoFactorSessionIDV2 in the verify-session response. A CLI + // cannot perform a WebAuthn ceremony, so quak must take the TOTP + // path using the V2 session ID. Returning { kind: "passkey" } here + // would make such accounts unusable from the CLI even though they + // have a perfectly good TOTP secret enrolled. + const fixture = await buildServerFixture(TEST_PASSWORD); + const api = new ApiClient({ + fetch: buildMockFetch(fixture, { requirePasskeyAndTOTP: true }), + }); + + const challenge = await beginLogin(api, TEST_EMAIL, TEST_PASSWORD); + + expect(challenge.kind).toBe("totp"); + if (challenge.kind === "totp") { + expect(challenge.sessionID).toBe("totp-session-v2-456"); + } + }); + it("rejects a wrong password (SRP M1 verification fails)", async () => { const fixture = await buildServerFixture(TEST_PASSWORD); const api = new ApiClient({ fetch: buildMockFetch(fixture) }); From 9fd7b6a8573c64939f848e19d14af137d5c248a5 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 10 Jun 2026 11:05:36 -0700 Subject: [PATCH 2/2] Green: prefer TOTP via twoFactorSessionIDV2 when passkey also enrolled beginLogin now checks twoFactorSessionID ?? twoFactorSessionIDV2 before the passkey branch. Accounts with both passkeys and TOTP can now log in from the CLI using their authenticator app. --- src/auth/login.ts | 10 ++++++++-- src/auth/types.ts | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/auth/login.ts b/src/auth/login.ts index 5a53f42..4123b1b 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -73,8 +73,14 @@ const srpHandshake = async ( srpClient.checkM2(Buffer.from(verifyResponse.srpM2, "base64")); - if (verifyResponse.twoFactorSessionID) { - return { kind: "totp", sessionID: verifyResponse.twoFactorSessionID }; + // twoFactorSessionIDV2 is set (instead of twoFactorSessionID) when the + // account has BOTH passkeys and TOTP. Prefer TOTP: a CLI cannot perform + // a WebAuthn ceremony, and the user has a TOTP secret enrolled. + const totpSessionID = + verifyResponse.twoFactorSessionID ?? + verifyResponse.twoFactorSessionIDV2; + if (totpSessionID) { + return { kind: "totp", sessionID: totpSessionID }; } if (verifyResponse.passkeySessionID) { return { diff --git a/src/auth/types.ts b/src/auth/types.ts index e6a23f4..5fd71e7 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -34,13 +34,18 @@ export interface SRPAttributes { // The body of a successful login response. Exactly one of the following // situations applies, and the caller dispatches on the populated fields: // - both keyAttributes and encryptedToken present: login is complete -// - twoFactorSessionID present: caller must submit a TOTP code -// - passkeySessionID present: caller must complete a passkey ceremony +// - twoFactorSessionID present: caller must submit a TOTP code (TOTP-only +// account) +// - passkeySessionID + twoFactorSessionIDV2 present: account has both +// passkeys and TOTP; the V2 field is set instead of twoFactorSessionID +// so that older clients keep using the passkey flow +// - only passkeySessionID present: caller must complete a passkey ceremony export interface AuthorizationResponse { id: number; keyAttributes?: KeyAttributes; encryptedToken?: Base64; twoFactorSessionID?: string; + twoFactorSessionIDV2?: string; passkeySessionID?: string; }