Handle browsers with incorrect time
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user