/** * Tests for the login flow functions: `beginLogin`, `submitTOTP`, * `requestEmailOTP`, and `submitEmailOTP`. * * These compose the pieces built so far (crypto KDF, SRP-6a via * fast-srp-hap, and ApiClient) into the complete authentication * handshake with an Ente server. * * The server side is simulated using fast-srp-hap's SrpServer class, so * the test exercises real SRP math end-to-end without network access. * * Login has three entry paths depending on the user's configuration: * * 1. **SRP (default)**: GET /users/srp/attributes, then a two-round SRP * handshake via POST /users/srp/create-session and * POST /users/srp/verify-session. The final response is either a * completed AuthorizationResponse or a 2FA challenge. * * 2. **Email OTP (isEmailMFAEnabled)**: if the SRP attributes indicate * email MFA is enabled, beginLogin returns `{ kind: "emailOTP" }` and * the caller is responsible for calling requestEmailOTP / submitEmailOTP * to complete the handshake. * * 3. **TOTP 2FA**: if the SRP handshake succeeds but the server requires * a second factor, beginLogin returns `{ kind: "totp", sessionID }`. * The caller calls submitTOTP with the sessionID and a 6-digit code * to obtain the AuthorizationResponse. */ import sodium from "libsodium-wrappers-sumo"; import { SRP, SrpServer } from "fast-srp-hap"; import { beforeAll, describe, expect, it } from "vitest"; import { ApiClient } from "../../src/api/client.js"; import { init, toBase64, deriveKEK, deriveLoginSubkey, } from "../../src/crypto/index.js"; import { beginLogin, submitTOTP, requestEmailOTP, submitEmailOTP, } from "../../src/auth/login.js"; import type { KeyAttributes } from "../../src/auth/types.js"; // --------------------------------------------------------------------------- // Shared test fixtures // --------------------------------------------------------------------------- const TEST_PASSWORD = "correct horse battery staple"; const TEST_EMAIL = "test@example.com"; const TEST_OPS = 2; const TEST_MEM = 64 * 1024 * 1024; /** * Builds everything needed to simulate an Ente server that has a user * registered with the given password. Returns: * - srpAttributes: what GET /users/srp/attributes returns * - verifier: the SRP verifier the server stores * - loginSubKey: the SRP password (base64) for building SrpServer * - keyAttributes + encryptedToken: for the auth response * - masterKey, secretKey, publicKey, tokenBytes: ground truth */ const buildServerFixture = async (password: string) => { await sodium.ready; const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); const kek = await deriveKEK(password, kekSalt, TEST_OPS, TEST_MEM); const loginSubKeyBytes = deriveLoginSubkey(kek); const srpUserID = "test-srp-user-id"; const srpSalt = sodium.randombytes_buf(16); const verifier = SRP.computeVerifier( SRP.params["4096"], Buffer.from(srpSalt), Buffer.from(srpUserID), Buffer.from(loginSubKeyBytes), ); // Build key attributes (same approach as unwrap.test.ts fixture) const masterKey = sodium.randombytes_buf(32); const keyDecryptionNonce = sodium.randombytes_buf( sodium.crypto_secretbox_NONCEBYTES, ); const encryptedKey = sodium.crypto_secretbox_easy( masterKey, keyDecryptionNonce, kek, ); const kp = sodium.crypto_box_keypair(); const secretKeyDecryptionNonce = sodium.randombytes_buf( sodium.crypto_secretbox_NONCEBYTES, ); const encryptedSecretKey = sodium.crypto_secretbox_easy( kp.privateKey, secretKeyDecryptionNonce, masterKey, ); const tokenBytes = sodium.randombytes_buf(64); const encryptedToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey); const keyAttributes: KeyAttributes = { kekSalt: toBase64(kekSalt), encryptedKey: toBase64(encryptedKey), keyDecryptionNonce: toBase64(keyDecryptionNonce), publicKey: toBase64(kp.publicKey), encryptedSecretKey: toBase64(encryptedSecretKey), secretKeyDecryptionNonce: toBase64(secretKeyDecryptionNonce), memLimit: TEST_MEM, opsLimit: TEST_OPS, }; return { srpAttributes: { srpUserID, srpSalt: toBase64(srpSalt), memLimit: TEST_MEM, opsLimit: TEST_OPS, kekSalt: toBase64(kekSalt), isEmailMFAEnabled: false, }, verifier, srpSalt: Buffer.from(srpSalt), loginSubKeyBytes, keyAttributes, encryptedToken: toBase64(encryptedToken), masterKey, secretKey: kp.privateKey, publicKey: kp.publicKey, tokenBytes, }; }; /** * Builds a mock fetch that simulates the Ente server's SRP endpoints. * The mock performs real SRP math via fast-srp-hap's SrpServer. * * @param fixture The server fixture from buildServerFixture * @param opts.requireTOTP If true, verify-session returns a 2FA challenge */ const buildMockFetch = ( fixture: Awaited>, opts?: { requireTOTP?: boolean }, ) => { let srpServer: SrpServer; const sessionID = "test-session-id"; const mockFetch = async ( input: RequestInfo | URL, init?: RequestInit, ): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const parsed = new URL(url); const path = parsed.pathname; if (path === "/users/srp/attributes") { return new Response( JSON.stringify({ attributes: fixture.srpAttributes }), { status: 200, headers: { "content-type": "application/json" }, }, ); } if (path === "/users/srp/create-session") { const body = JSON.parse(init?.body as string); const serverKey = await SRP.genKey(); srpServer = new SrpServer( SRP.params["4096"], fixture.verifier, serverKey, ); const B = srpServer.computeB(); srpServer.setA(Buffer.from(body.srpA, "base64")); return new Response( JSON.stringify({ sessionID, srpB: B.toString("base64"), }), { status: 200, headers: { "content-type": "application/json" }, }, ); } if (path === "/users/srp/verify-session") { const body = JSON.parse(init?.body as string); srpServer.checkM1(Buffer.from(body.srpM1, "base64")); const M2 = srpServer.computeM2(); if (opts?.requireTOTP) { return new Response( JSON.stringify({ srpM2: M2.toString("base64"), twoFactorSessionID: "totp-session-999", }), { status: 200, headers: { "content-type": "application/json" }, }, ); } return new Response( JSON.stringify({ srpM2: M2.toString("base64"), id: 42, keyAttributes: fixture.keyAttributes, encryptedToken: fixture.encryptedToken, }), { status: 200, headers: { "content-type": "application/json" }, }, ); } if (path === "/users/two-factor/verify") { return new Response( JSON.stringify({ id: 42, keyAttributes: fixture.keyAttributes, encryptedToken: fixture.encryptedToken, }), { status: 200, headers: { "content-type": "application/json" }, }, ); } if (path === "/users/ott") { return new Response(JSON.stringify({}), { status: 200, headers: { "content-type": "application/json" }, }); } if (path === "/users/verify-email") { return new Response( JSON.stringify({ id: 42, keyAttributes: fixture.keyAttributes, encryptedToken: fixture.encryptedToken, }), { status: 200, headers: { "content-type": "application/json" }, }, ); } return new Response("Not Found", { status: 404 }); }; return mockFetch as typeof globalThis.fetch; }; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("beginLogin via SRP", () => { beforeAll(async () => { await init(); }); it("completes SRP login and returns { kind: 'complete' }", async () => { const fixture = await buildServerFixture(TEST_PASSWORD); const api = new ApiClient({ fetch: buildMockFetch(fixture) }); const challenge = await beginLogin(api, TEST_EMAIL, TEST_PASSWORD); expect(challenge.kind).toBe("complete"); if (challenge.kind === "complete") { expect(challenge.response.id).toBe(42); expect(challenge.response.keyAttributes).toBeDefined(); expect(challenge.response.encryptedToken).toBeDefined(); } }); it("returns { kind: 'totp' } when 2FA is required", async () => { const fixture = await buildServerFixture(TEST_PASSWORD); const api = new ApiClient({ fetch: buildMockFetch(fixture, { requireTOTP: 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-999"); } }); it("rejects a wrong password (SRP M1 verification fails)", async () => { const fixture = await buildServerFixture(TEST_PASSWORD); const api = new ApiClient({ fetch: buildMockFetch(fixture) }); await expect( beginLogin(api, TEST_EMAIL, "wrong password"), ).rejects.toThrow(); }); }); describe("beginLogin with email MFA fallback", () => { beforeAll(async () => { await init(); }); it("returns { kind: 'emailOTP' } when isEmailMFAEnabled", async () => { const fixture = await buildServerFixture(TEST_PASSWORD); fixture.srpAttributes.isEmailMFAEnabled = true; const api = new ApiClient({ fetch: buildMockFetch(fixture) }); const challenge = await beginLogin(api, TEST_EMAIL, TEST_PASSWORD); expect(challenge.kind).toBe("emailOTP"); }); }); describe("submitTOTP", () => { beforeAll(async () => { await init(); }); it("sends code to /users/two-factor/verify and returns AuthorizationResponse", async () => { const fixture = await buildServerFixture(TEST_PASSWORD); const api = new ApiClient({ fetch: buildMockFetch(fixture) }); const response = await submitTOTP(api, "totp-session-999", "123456"); expect(response.id).toBe(42); expect(response.keyAttributes).toBeDefined(); expect(response.encryptedToken).toBeDefined(); }); }); describe("requestEmailOTP / submitEmailOTP", () => { beforeAll(async () => { await init(); }); it("requestEmailOTP sends to /users/ott without throwing", async () => { const fixture = await buildServerFixture(TEST_PASSWORD); const api = new ApiClient({ fetch: buildMockFetch(fixture) }); await expect(requestEmailOTP(api, TEST_EMAIL)).resolves.toBeUndefined(); }); it("submitEmailOTP sends to /users/verify-email and returns AuthorizationResponse", async () => { const fixture = await buildServerFixture(TEST_PASSWORD); const api = new ApiClient({ fetch: buildMockFetch(fixture) }); const response = await submitEmailOTP(api, TEST_EMAIL, "654321"); expect(response.id).toBe(42); expect(response.keyAttributes).toBeDefined(); expect(response.encryptedToken).toBeDefined(); }); });