Merge: TOTP login for accounts that also have passkeys

The server signals dual-2FA accounts with passkeySessionID +
twoFactorSessionIDV2. quak now takes the TOTP path in that case
instead of failing with 'Passkey authentication is not supported'.
This commit is contained in:
2026-06-10 11:05:39 -07:00
3 changed files with 55 additions and 5 deletions

View File

@@ -73,8 +73,14 @@ const srpHandshake = async (
srpClient.checkM2(Buffer.from(verifyResponse.srpM2, "base64")); srpClient.checkM2(Buffer.from(verifyResponse.srpM2, "base64"));
if (verifyResponse.twoFactorSessionID) { // twoFactorSessionIDV2 is set (instead of twoFactorSessionID) when the
return { kind: "totp", sessionID: verifyResponse.twoFactorSessionID }; // 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) { if (verifyResponse.passkeySessionID) {
return { return {

View File

@@ -34,13 +34,18 @@ export interface SRPAttributes {
// The body of a successful login response. Exactly one of the following // The body of a successful login response. Exactly one of the following
// situations applies, and the caller dispatches on the populated fields: // situations applies, and the caller dispatches on the populated fields:
// - both keyAttributes and encryptedToken present: login is complete // - both keyAttributes and encryptedToken present: login is complete
// - twoFactorSessionID present: caller must submit a TOTP code // - twoFactorSessionID present: caller must submit a TOTP code (TOTP-only
// - passkeySessionID present: caller must complete a passkey ceremony // 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 { export interface AuthorizationResponse {
id: number; id: number;
keyAttributes?: KeyAttributes; keyAttributes?: KeyAttributes;
encryptedToken?: Base64; encryptedToken?: Base64;
twoFactorSessionID?: string; twoFactorSessionID?: string;
twoFactorSessionIDV2?: string;
passkeySessionID?: string; passkeySessionID?: string;
} }

View File

@@ -145,7 +145,7 @@ const buildServerFixture = async (password: string) => {
*/ */
const buildMockFetch = ( const buildMockFetch = (
fixture: Awaited<ReturnType<typeof buildServerFixture>>, fixture: Awaited<ReturnType<typeof buildServerFixture>>,
opts?: { requireTOTP?: boolean }, opts?: { requireTOTP?: boolean; requirePasskeyAndTOTP?: boolean },
) => { ) => {
let srpServer: SrpServer; let srpServer: SrpServer;
const sessionID = "test-session-id"; 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( return new Response(
JSON.stringify({ JSON.stringify({
srpM2: M2.toString("base64"), 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 () => { it("rejects a wrong password (SRP M1 verification fails)", async () => {
const fixture = await buildServerFixture(TEST_PASSWORD); const fixture = await buildServerFixture(TEST_PASSWORD);
const api = new ApiClient({ fetch: buildMockFetch(fixture) }); const api = new ApiClient({ fetch: buildMockFetch(fixture) });