Handle browsers with incorrect time

This commit is contained in:
Manav Rathi
2025-05-30 12:27:17 +05:30
parent a33af20944
commit 2b390b60c4
2 changed files with 100 additions and 32 deletions

View File

@@ -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<Code[]>([]);
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 = () => {
</Box>
) : (
filteredCodes.map((code) => (
<CodeDisplay key={code.id} code={code} />
<CodeDisplay
key={code.id}
{...{ code, timeOffset }}
/>
))
)}
</Box>
@@ -162,9 +169,10 @@ const AuthNavbar: React.FC = () => {
interface CodeDisplayProps {
code: Code;
timeOffset: number;
}
const CodeDisplay: React.FC<CodeDisplayProps> = ({ code }) => {
const CodeDisplay: React.FC<CodeDisplayProps> = ({ code, timeOffset }) => {
const [otp, setOTP] = useState("");
const [nextOTP, setNextOTP] = useState("");
const [errorMessage, setErrorMessage] = useState("");
@@ -191,7 +199,8 @@ const CodeDisplay: React.FC<CodeDisplayProps> = ({ code }) => {
regen();
const periodMs = code.period * 1000;
const timeToNextCode = periodMs - (Date.now() % periodMs);
const timeToNextCode =
periodMs - ((Date.now() + timeOffset) % periodMs);
let interval: ReturnType<typeof setInterval> | undefined;
// Wait until we are at the start of the next code period, and then
@@ -204,7 +213,7 @@ const CodeDisplay: React.FC<CodeDisplayProps> = ({ code }) => {
}, timeToNextCode);
return () => interval && clearInterval(interval);
}, [code, regen]);
}, [code, timeOffset, regen]);
return (
<Box sx={{ p: 1 }}>
@@ -212,7 +221,7 @@ const CodeDisplay: React.FC<CodeDisplayProps> = ({ code }) => {
<UnparseableCode {...{ code, errorMessage }} />
) : (
<ButtonBase component="div" onClick={copyCode}>
<OTPDisplay {...{ code, otp, nextOTP }} />
<OTPDisplay {...{ code, timeOffset, otp, nextOTP }} />
<Snackbar
open={openCopied}
message={t("copied")}
@@ -232,13 +241,14 @@ const CodeDisplay: React.FC<CodeDisplayProps> = ({ code }) => {
);
};
interface OTPDisplayProps {
code: Code;
otp: string;
nextOTP: string;
}
type OTPDisplayProps = CodeValidityBarProps & { otp: string; nextOTP: string };
const OTPDisplay: React.FC<OTPDisplayProps> = ({ code, otp, nextOTP }) => {
const OTPDisplay: React.FC<OTPDisplayProps> = ({
code,
timeOffset,
otp,
nextOTP,
}) => {
return (
<Box
sx={(theme) => ({
@@ -247,7 +257,7 @@ const OTPDisplay: React.FC<OTPDisplayProps> = ({ code, otp, nextOTP }) => {
overflow: "hidden",
})}
>
<CodeValidityBar code={code} />
<CodeValidityBar {...{ code, timeOffset }} />
<Stack
direction="row"
sx={{
@@ -295,16 +305,21 @@ const OTPDisplay: React.FC<OTPDisplayProps> = ({ code, otp, nextOTP }) => {
interface CodeValidityBarProps {
code: Code;
timeOffset: number;
}
const CodeValidityBar: React.FC<CodeValidityBarProps> = ({ code }) => {
const CodeValidityBar: React.FC<CodeValidityBarProps> = ({
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);
};

View File

@@ -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<Code[]> => {
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<AuthCodesAndTimeOffset> => {
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<Code[]> => {
}
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<AuthenticatorEntity[]> => {
): Promise<AuthenticatorEntityDiffResult> => {
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({