Files
quak/test/auth/login.test.ts
sneak 2c51074294 Red: mock server must serialize empty 2FA fields like Go does
The museum server's EmailAuthorizationResponse declares
passkeySessionID, accountsUrl, twoFactorSessionID, and
twoFactorSessionIDV2 without `omitempty`, so Go always sends them,
as "" when unset. The previous mock omitted the unset fields
entirely, which let the ?? -based dispatch pass in tests while the
real server's "" defeated it and dual-2FA logins fell through to
the unsupported passkey branch.
2026-06-10 11:25:25 -07:00

434 lines
15 KiB
TypeScript

/**
* 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; requirePasskeyAndTOTP?: 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();
// IMPORTANT: the museum server's EmailAuthorizationResponse
// (server/ente/user.go) declares passkeySessionID, accountsUrl,
// twoFactorSessionID, and twoFactorSessionIDV2 WITHOUT the
// `omitempty` JSON tag. Go therefore always serializes them,
// sending "" (empty string, NOT null/absent) for any that do
// not apply. These mocks must reproduce that faithfully: a
// client that distinguishes fields with `??` instead of `||`
// passes against an omitting mock but breaks against the real
// server.
if (opts?.requireTOTP) {
return new Response(
JSON.stringify({
id: 42,
srpM2: M2.toString("base64"),
passkeySessionID: "",
accountsUrl: "",
twoFactorSessionID: "totp-session-999",
twoFactorSessionIDV2: "",
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
}
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).
// The V1 field is still present on the wire as "".
return new Response(
JSON.stringify({
id: 42,
srpM2: M2.toString("base64"),
passkeySessionID: "passkey-session-123",
accountsUrl: "https://accounts.ente.io",
twoFactorSessionID: "",
twoFactorSessionIDV2: "totp-session-v2-456",
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
}
return new Response(
JSON.stringify({
srpM2: M2.toString("base64"),
id: 42,
keyAttributes: fixture.keyAttributes,
encryptedToken: fixture.encryptedToken,
passkeySessionID: "",
accountsUrl: "",
twoFactorSessionID: "",
twoFactorSessionIDV2: "",
}),
{
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("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 () => {
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();
});
});