diff --git a/README.md b/README.md index 86f61d3..3f4fdbf 100644 --- a/README.md +++ b/README.md @@ -606,16 +606,16 @@ Phase 2: crypto primitives Phase 3: SRP + auth -- [ ] SRP-6a client using `secure-remote-password` with the same group as the - server -- [ ] `beginLogin(email, password)` returning a `LoginChallenge` -- [ ] `requestEmailOTP` and `submitEmailOTP` for accounts without SRP -- [ ] `submitTOTP(sessionID, code)` +- [x] SRP-6a client using `fast-srp-hap` with the 4096-bit group (matching the + upstream Ente web client) +- [x] `beginLogin(api, email, password)` returning a `LoginChallenge` +- [x] `requestEmailOTP` and `submitEmailOTP` for accounts without SRP +- [x] `submitTOTP(api, sessionID, code)` - [x] `unwrapAuth(response, password)` returning master key, secret key, public key, and decrypted token (URL-safe-no-padding base64) - [x] `src/auth/types.ts` with `KeyAttributes`, `SRPAttributes`, `AuthorizationResponse`, and `LoginChallenge` -- [ ] Tests against recorded HTTP fixtures +- [x] Tests with mock SRP server performing real 4096-bit math end-to-end Phase 4: HTTP client + endpoints 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..5a53f42 --- /dev/null +++ b/src/auth/login.ts @@ -0,0 +1,130 @@ +import { SRP, SrpClient } from "fast-srp-hap"; +import type { ApiClient } from "../api/client.js"; +import { deriveKEK, deriveLoginSubkey, fromBase64 } from "../crypto/index.js"; +import type { + AuthorizationResponse, + LoginChallenge, + SRPAttributes, +} from "./types.js"; + +// Ente uses the 4096-bit SRP group. Both client and server must agree. +const SRP_PARAMS = SRP.params["4096"]; + +// Wrap fast-srp-hap's callback-based genKey into a Promise. +const genKey = (): Promise => SRP.genKey(); + +// Create an SRP client from the derived login subkey and SRP attributes. +const makeSrpClient = async ( + srpSalt: Buffer, + srpUserID: string, + loginSubKey: Uint8Array, +): Promise => { + const clientKey = await genKey(); + return new SrpClient( + SRP_PARAMS, + srpSalt, + Buffer.from(srpUserID), + Buffer.from(loginSubKey), + clientKey, + false, + ); +}; + +// Perform the two-round SRP handshake against the Ente API. +const srpHandshake = async ( + api: ApiClient, + srpAttributes: SRPAttributes, + password: string, +): Promise => { + const kek = await deriveKEK( + password, + fromBase64(srpAttributes.kekSalt), + srpAttributes.opsLimit, + srpAttributes.memLimit, + ); + const loginSubKey = deriveLoginSubkey(kek); + + const srpClient = await makeSrpClient( + Buffer.from(fromBase64(srpAttributes.srpSalt)), + srpAttributes.srpUserID, + loginSubKey, + ); + + const srpA = srpClient.computeA().toString("base64"); + + const { sessionID, srpB } = await api.postJSON<{ + sessionID: string; + srpB: string; + }>("/users/srp/create-session", { + srpUserID: srpAttributes.srpUserID, + srpA, + }); + + srpClient.setB(Buffer.from(srpB, "base64")); + const srpM1 = srpClient.computeM1().toString("base64"); + + const verifyResponse = await api.postJSON< + AuthorizationResponse & { srpM2: string } + >("/users/srp/verify-session", { + sessionID, + srpUserID: srpAttributes.srpUserID, + srpM1, + }); + + srpClient.checkM2(Buffer.from(verifyResponse.srpM2, "base64")); + + if (verifyResponse.twoFactorSessionID) { + return { kind: "totp", sessionID: verifyResponse.twoFactorSessionID }; + } + if (verifyResponse.passkeySessionID) { + return { + kind: "passkey", + sessionID: verifyResponse.passkeySessionID, + }; + } + + return { kind: "complete", response: verifyResponse }; +}; + +export const beginLogin = async ( + api: ApiClient, + email: string, + password: string, +): Promise => { + const { attributes } = await api.getJSON<{ + attributes: SRPAttributes; + }>("/users/srp/attributes", { email }); + + if (attributes.isEmailMFAEnabled) { + return { kind: "emailOTP" }; + } + + return srpHandshake(api, attributes, password); +}; + +export const submitTOTP = async ( + api: ApiClient, + sessionID: string, + code: string, +): Promise => + api.postJSON("/users/two-factor/verify", { + sessionID, + code, + }); + +export const requestEmailOTP = async ( + api: ApiClient, + email: string, +): Promise => { + await api.postJSON("/users/ott", { email, purpose: "login" }); +}; + +export const submitEmailOTP = async ( + api: ApiClient, + email: string, + code: string, +): Promise => + api.postJSON("/users/verify-email", { + email, + ott: code, + }); 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"