diff --git a/package.json b/package.json index 5ae29a9..21e4328 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "vitest": "2.1.9" }, "dependencies": { + "fast-srp-hap": "2.0.4", "libsodium-wrappers-sumo": "0.8.4" } } diff --git a/src/auth/login.ts b/src/auth/login.ts new file mode 100644 index 0000000..8d474e2 --- /dev/null +++ b/src/auth/login.ts @@ -0,0 +1,35 @@ +// Stub: see the README "Development workflow" section for TDD policy. + +import type { ApiClient } from "../api/client.js"; +import type { AuthorizationResponse, LoginChallenge } from "./types.js"; + +export const beginLogin = async ( + _api: ApiClient, + _email: string, + _password: string, +): Promise => { + throw new Error("auth.beginLogin not implemented"); +}; + +export const submitTOTP = async ( + _api: ApiClient, + _sessionID: string, + _code: string, +): Promise => { + throw new Error("auth.submitTOTP not implemented"); +}; + +export const requestEmailOTP = async ( + _api: ApiClient, + _email: string, +): Promise => { + throw new Error("auth.requestEmailOTP not implemented"); +}; + +export const submitEmailOTP = async ( + _api: ApiClient, + _email: string, + _code: string, +): Promise => { + throw new Error("auth.submitEmailOTP not implemented"); +}; diff --git a/test/auth/login.test.ts b/test/auth/login.test.ts new file mode 100644 index 0000000..0519a22 --- /dev/null +++ b/test/auth/login.test.ts @@ -0,0 +1,373 @@ +/** + * 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(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 77b0200..d958d9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -879,6 +879,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-srp-hap@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/fast-srp-hap/-/fast-srp-hap-2.0.4.tgz#9db296e21a5143951310f99e5a74290106467811" + integrity sha512-lHRYYaaIbMrhZtsdGTwPN82UbqD9Bv8QfOlKs+Dz6YRnByZifOh93EYmf2iEWFtkOEIqR2IK8cFD0UN5wLIWBQ== + fastq@^1.6.0: version "1.20.1" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675"