diff --git a/web/apps/auth/src/pages/auth.tsx b/web/apps/auth/src/pages/auth.tsx index 485511edba..f8b160af52 100644 --- a/web/apps/auth/src/pages/auth.tsx +++ b/web/apps/auth/src/pages/auth.tsx @@ -27,13 +27,14 @@ import { t } from "i18next"; import { useRouter } from "next/router"; import React, { useCallback, useEffect, useState } from "react"; import { generateOTPs, type Code } from "services/code"; -import { getAuthCodes } from "services/remote"; +import { getAuthCodesAndTimeOffset } from "services/remote"; const Page: React.FC = () => { const { logout, showMiniDialog } = useBaseContext(); const router = useRouter(); const [codes, setCodes] = useState([]); + const [timeOffset, setTimeOffset] = useState(0); const [hasFetched, setHasFetched] = useState(false); const [searchTerm, setSearchTerm] = useState(""); @@ -47,7 +48,10 @@ const Page: React.FC = () => { } try { - setCodes(await getAuthCodes(masterKey)); + const { codes, timeOffset } = + await getAuthCodesAndTimeOffset(masterKey); + setCodes(codes); + setTimeOffset(timeOffset ?? 0); } catch (e) { log.error("Failed to fetch codes", e); if (isHTTP401Error(e)) @@ -117,7 +121,10 @@ const Page: React.FC = () => { ) : ( filteredCodes.map((code) => ( - + )) )} @@ -162,9 +169,10 @@ const AuthNavbar: React.FC = () => { interface CodeDisplayProps { code: Code; + timeOffset: number; } -const CodeDisplay: React.FC = ({ code }) => { +const CodeDisplay: React.FC = ({ code, timeOffset }) => { const [otp, setOTP] = useState(""); const [nextOTP, setNextOTP] = useState(""); const [errorMessage, setErrorMessage] = useState(""); @@ -191,7 +199,8 @@ const CodeDisplay: React.FC = ({ code }) => { regen(); const periodMs = code.period * 1000; - const timeToNextCode = periodMs - (Date.now() % periodMs); + const timeToNextCode = + periodMs - ((Date.now() + timeOffset) % periodMs); let interval: ReturnType | undefined; // Wait until we are at the start of the next code period, and then @@ -204,7 +213,7 @@ const CodeDisplay: React.FC = ({ code }) => { }, timeToNextCode); return () => interval && clearInterval(interval); - }, [code, regen]); + }, [code, timeOffset, regen]); return ( @@ -212,7 +221,7 @@ const CodeDisplay: React.FC = ({ code }) => { ) : ( - + = ({ code }) => { ); }; -interface OTPDisplayProps { - code: Code; - otp: string; - nextOTP: string; -} +type OTPDisplayProps = CodeValidityBarProps & { otp: string; nextOTP: string }; -const OTPDisplay: React.FC = ({ code, otp, nextOTP }) => { +const OTPDisplay: React.FC = ({ + code, + timeOffset, + otp, + nextOTP, +}) => { return ( ({ @@ -247,7 +257,7 @@ const OTPDisplay: React.FC = ({ code, otp, nextOTP }) => { overflow: "hidden", })} > - + = ({ code, otp, nextOTP }) => { interface CodeValidityBarProps { code: Code; + timeOffset: number; } -const CodeValidityBar: React.FC = ({ code }) => { +const CodeValidityBar: React.FC = ({ + code, + timeOffset, +}) => { const theme = useTheme(); const [progress, setProgress] = useState(code.type == "hotp" ? 1 : 0); useEffect(() => { const advance = () => { const us = code.period * 1e6; - const timeRemaining = us - ((Date.now() * 1000) % us); + const timeRemaining = + us - (((Date.now() + timeOffset) * 1000) % us); setProgress(timeRemaining / us); }; diff --git a/web/apps/auth/src/services/remote.ts b/web/apps/auth/src/services/remote.ts index eccf7ab402..05d84c1375 100644 --- a/web/apps/auth/src/services/remote.ts +++ b/web/apps/auth/src/services/remote.ts @@ -7,21 +7,37 @@ import { import log from "ente-base/log"; import { apiURL } from "ente-base/origins"; import { ensureString } from "ente-utils/ensure"; +import { nullToUndefined } from "ente-utils/transform"; import { codeFromURIString, type Code } from "services/code"; import { z } from "zod"; -export const getAuthCodes = async (masterKey: Uint8Array): Promise => { +export interface AuthCodesAndTimeOffset { + codes: Code[]; + /** + * An optional and approximate correction which should be applied to the + * current client's time when deriving TOTPs. + */ + timeOffset?: number; +} + +export const getAuthCodesAndTimeOffset = async ( + masterKey: Uint8Array, +): Promise => { const authenticatorEntityKey = await getAuthenticatorEntityKey(); if (!authenticatorEntityKey) { // The user might not have stored any codes yet from the mobile app. - return []; + return { codes: [] }; } const authenticatorKey = await decryptAuthenticatorKey( authenticatorEntityKey, masterKey, ); - return (await authenticatorEntityDiff(authenticatorKey)) + + const { entities, timeOffset } = + await authenticatorEntityDiff(authenticatorKey); + + const codes = entities .map((entity) => { try { return codeFromURIString(entity.id, ensureString(entity.data)); @@ -53,6 +69,8 @@ export const getAuthCodes = async (masterKey: Uint8Array): Promise => { } return 0; }); + + return { codes, timeOffset }; }; /** @@ -93,15 +111,43 @@ const RemoteAuthenticatorEntityChange = z.object({ updatedAt: z.number(), }); +const AuthenticatorEntityDiffResponse = z.object({ + diff: z.array(RemoteAuthenticatorEntityChange), + timestamp: z.number().nullish().transform(nullToUndefined), +}); + +export interface AuthenticatorEntityDiffResult { + /** + * The decrypted {@link AuthenticatorEntity} values. + */ + entities: AuthenticatorEntity[]; + /** + * An optional and approximate offset by which the time on the current client + * is out of sync. + * + * This offset is computed by calculated by comparing the timestamp when the + * remote generated the response to the time we received it. As such + * (because of network delays etc) this will not be an accurate offset, + * neither is it meant to be - it is only meant to help users whose devices + * have wildly off times to still see the correct codes. + * + * Note that, for various reasons, remote might not send us a timestamp when + * fetching the diff, so this is a best effort correction, and is not + * guaranteed to be present. + */ + timeOffset: number | undefined; +} + /** - * Fetch all the authenticator entities for the user. + * Fetch all the authenticator entities for the user, and an estimated time + * drift for the current client. * * @param authenticatorKey The (base64 encoded) key that should be used for * decrypting the authenticator entities received from remote. */ export const authenticatorEntityDiff = async ( authenticatorKey: string, -): Promise => { +): Promise => { const decrypt = (encryptedData: string, decryptionHeader: string) => decryptMetadataJSON_New( { encryptedData, decryptionHeader }, @@ -109,13 +155,15 @@ export const authenticatorEntityDiff = async ( ); // Fetch all the entities, paginating the requests. - const entities = new Map< + const encryptedEntities = new Map< string, { id: string; encryptedData: string; header: string } >(); let sinceTime = 0; const batchSize = 2500; + let timeOffset: number | undefined = undefined; + while (true) { const params = new URLSearchParams({ sinceTime: `${sinceTime}`, @@ -126,17 +174,18 @@ export const authenticatorEntityDiff = async ( headers: await authenticatedRequestHeaders(), }); ensureOk(res); - const diff = z - .object({ diff: z.array(RemoteAuthenticatorEntityChange) }) - .parse(await res.json()).diff; + const { diff, timestamp } = AuthenticatorEntityDiffResponse.parse( + await res.json(), + ); + if (timestamp) timeOffset = Date.now() - timestamp; if (diff.length == 0) break; for (const change of diff) { sinceTime = Math.max(sinceTime, change.updatedAt); if (change.isDeleted) { - entities.delete(change.id); + encryptedEntities.delete(change.id); } else { - entities.set(change.id, { + encryptedEntities.set(change.id, { id: change.id, encryptedData: change.encryptedData!, header: change.header!, @@ -145,12 +194,16 @@ export const authenticatorEntityDiff = async ( } } - return Promise.all( - [...entities.values()].map(async ({ id, encryptedData, header }) => ({ - id, - data: await decrypt(encryptedData, header), - })), + const entities = await Promise.all( + [...encryptedEntities.values()].map( + async ({ id, encryptedData, header }) => ({ + id, + data: await decrypt(encryptedData, header), + }), + ), ); + + return { entities, timeOffset }; }; export const AuthenticatorEntityKey = z.object({