Phase 3b green: implement login flow (SRP + email OTP + TOTP)

beginLogin(api, email, password):
  1. Fetches SRP attributes for the email
  2. If isEmailMFAEnabled, returns { kind: 'emailOTP' } immediately
  3. Otherwise: derives KEK + login subkey, creates SRP client with
     4096-bit group, runs the two-round handshake (create-session,
     verify-session), verifies server M2
  4. Returns { kind: 'complete' } with AuthorizationResponse, or
     { kind: 'totp' } / { kind: 'passkey' } if 2FA is required

submitTOTP(api, sessionID, code): POST /users/two-factor/verify
requestEmailOTP(api, email): POST /users/ott
submitEmailOTP(api, email, code): POST /users/verify-email

All 64 tests pass including real SRP-6a handshakes against a mock
server built with fast-srp-hap's SrpServer.
This commit is contained in:
2026-05-11 10:11:19 -07:00
parent 75b57cfb29
commit dcec9b92ad

View File

@@ -1,35 +1,130 @@
// Stub: see the README "Development workflow" section for TDD policy.
import { SRP, SrpClient } from "fast-srp-hap";
import type { ApiClient } from "../api/client.js";
import type { AuthorizationResponse, LoginChallenge } from "./types.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<Buffer> => SRP.genKey();
// Create an SRP client from the derived login subkey and SRP attributes.
const makeSrpClient = async (
srpSalt: Buffer,
srpUserID: string,
loginSubKey: Uint8Array,
): Promise<SrpClient> => {
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<LoginChallenge> => {
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,
api: ApiClient,
email: string,
password: string,
): Promise<LoginChallenge> => {
throw new Error("auth.beginLogin not implemented");
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<AuthorizationResponse> => {
throw new Error("auth.submitTOTP not implemented");
};
api: ApiClient,
sessionID: string,
code: string,
): Promise<AuthorizationResponse> =>
api.postJSON<AuthorizationResponse>("/users/two-factor/verify", {
sessionID,
code,
});
export const requestEmailOTP = async (
_api: ApiClient,
_email: string,
api: ApiClient,
email: string,
): Promise<void> => {
throw new Error("auth.requestEmailOTP not implemented");
await api.postJSON("/users/ott", { email, purpose: "login" });
};
export const submitEmailOTP = async (
_api: ApiClient,
_email: string,
_code: string,
): Promise<AuthorizationResponse> => {
throw new Error("auth.submitEmailOTP not implemented");
};
api: ApiClient,
email: string,
code: string,
): Promise<AuthorizationResponse> =>
api.postJSON<AuthorizationResponse>("/users/verify-email", {
email,
ott: code,
});