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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user