Phase 3b red: login flow tests with SRP mock server
Adds fast-srp-hap (the same SRP library Ente's web client uses, pinned
to 2.0.4) as a runtime dependency.
Tests build a full mock Ente server using fast-srp-hap's SrpServer to
exercise real SRP-6a math end-to-end. The mock handles:
GET /users/srp/attributes
POST /users/srp/create-session
POST /users/srp/verify-session
POST /users/two-factor/verify
POST /users/ott
POST /users/verify-email
7 tests covering:
* SRP login completing successfully
* SRP login requiring TOTP (returns { kind: 'totp' })
* Wrong password (SRP M1 fails server-side checkM1)
* Email MFA fallback (returns { kind: 'emailOTP' })
* submitTOTP
* requestEmailOTP + submitEmailOTP
This commit is contained in:
373
test/auth/login.test.ts
Normal file
373
test/auth/login.test.ts
Normal file
@@ -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<ReturnType<typeof buildServerFixture>>,
|
||||
opts?: { requireTOTP?: boolean },
|
||||
) => {
|
||||
let srpServer: SrpServer;
|
||||
const sessionID = "test-session-id";
|
||||
|
||||
const mockFetch = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user