From dcec9b92ad5327c8cc07788f2b5f944c616ff70a Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 11 May 2026 10:11:19 -0700 Subject: [PATCH] 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. --- src/auth/login.ts | 139 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 117 insertions(+), 22 deletions(-) diff --git a/src/auth/login.ts b/src/auth/login.ts index 8d474e2..5a53f42 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -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 => 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, + api: ApiClient, + email: string, + password: string, ): Promise => { - 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 => { - throw new Error("auth.submitTOTP not implemented"); -}; + api: ApiClient, + sessionID: string, + code: string, +): Promise => + api.postJSON("/users/two-factor/verify", { + sessionID, + code, + }); export const requestEmailOTP = async ( - _api: ApiClient, - _email: string, + api: ApiClient, + email: string, ): Promise => { - 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 => { - throw new Error("auth.submitEmailOTP not implemented"); -}; + api: ApiClient, + email: string, + code: string, +): Promise => + api.postJSON("/users/verify-email", { + email, + ott: code, + });