feat: add timezone-aware TOTP time travel

This commit is contained in:
2026-08-19 13:00:06 +02:00
parent f1cb2d7151
commit 603559c540
9 changed files with 720 additions and 19 deletions
+5
View File
@@ -2,6 +2,11 @@
All notable changes are documented here.
## Unreleased
- Added auditable TOTP time travel using live time, bounded positive or negative period offsets, or a frozen local date/time interpreted in an explicit IANA timezone.
- Added DST gap rejection and earlier/later disambiguation for repeated wall-clock times, with effective local time, UTC instant, Unix seconds and TOTP counter diagnostics.
## 0.1.0 - 2026-08-19
- Initial HOTP, TOTP and OCRA credential laboratory with official RFC-vector coverage.
+2 -1
View File
@@ -4,10 +4,11 @@ A production-oriented, local-first browser workbench for OTP credentials and Web
Authentication material stays in the active tab. The application has no backend, telemetry, automatic network lookup, service worker, cookie, local-storage credential store, or IndexedDB database.
## Included in 0.1.0
## Current source capabilities
- RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes.
- Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder.
- Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation.
- RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths.
- Import of line-delimited provisioning URIs, Google Authenticator migration QR payloads, the documented CSV shape and RFC 6030 PSKC files containing plain secrets. Encrypted PSKC is rejected rather than guessed.
- Explicit URI-list and CSV export with an unencrypted-secret warning.
+5
View File
@@ -2,6 +2,11 @@
All notable changes are documented here.
## Unreleased
- Added auditable TOTP time travel using live time, bounded positive or negative period offsets, or a frozen local date/time interpreted in an explicit IANA timezone.
- Added DST gap rejection and earlier/later disambiguation for repeated wall-clock times, with effective local time, UTC instant, Unix seconds and TOTP counter diagnostics.
## 0.1.0 - 2026-08-19
- Initial HOTP, TOTP and OCRA credential laboratory with official RFC-vector coverage.
+2 -1
View File
@@ -4,10 +4,11 @@ A production-oriented, local-first browser workbench for OTP credentials and Web
Authentication material stays in the active tab. The application has no backend, telemetry, automatic network lookup, service worker, cookie, local-storage credential store, or IndexedDB database.
## Included in 0.1.0
## Current source capabilities
- RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes.
- Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder.
- Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation.
- RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths.
- Import of line-delimited provisioning URIs, Google Authenticator migration QR payloads, the documented CSV shape and RFC 6030 PSKC files containing plain secrets. Encrypted PSKC is rejected rather than guessed.
- Explicit URI-list and CSV export with an unencrypted-secret warning.
+291 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
base32ToBytes,
bytesToBase32,
@@ -16,6 +16,7 @@ import {
import {
hotp,
totp,
totpCounter,
verifyHotp,
verifyTotp,
type OtpHashAlgorithm,
@@ -27,8 +28,26 @@ import {
serializeOtpAuth,
type OtpProfile,
} from "../../otp/profile";
import {
formatDateTimeLocal,
MAX_TOTP_PERIOD_OFFSET,
resolveZonedDateTime,
shiftTimestampByPeriods,
supportedTimeZones,
type TimeDisambiguation,
} from "../../otp/time";
import { qrSvg } from "../../qr/encoder";
type TotpTimeMode = "current" | "relative" | "absolute";
function browserTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
}
function timeError(reason: unknown): string {
return reason instanceof Error ? reason.message : "Invalid TOTP time.";
}
function downloadText(
name: string,
content: string,
@@ -82,7 +101,16 @@ export function OtpWorkspace() {
);
const [secretVisible, setSecretVisible] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [totpTimeMode, setTotpTimeMode] = useState<TotpTimeMode>("current");
const [periodOffset, setPeriodOffset] = useState("0");
const [timeZone, setTimeZone] = useState(browserTimeZone);
const [absoluteTime, setAbsoluteTime] = useState(() =>
formatDateTimeLocal(Date.now(), browserTimeZone()),
);
const [timeDisambiguation, setTimeDisambiguation] =
useState<TimeDisambiguation>("earlier");
const [code, setCode] = useState("······");
const generationId = useRef(0);
const [verifyCode, setVerifyCode] = useState("");
const [verification, setVerification] = useState("");
const [error, setError] = useState("");
@@ -93,12 +121,52 @@ export function OtpWorkspace() {
const [imported, setImported] = useState<OtpProfile[]>([]);
const [warnings, setWarnings] = useState<string[]>([]);
const timeZones = useMemo(() => supportedTimeZones(), []);
const resolvedTime = useMemo(() => {
try {
if (totpTimeMode === "current")
return { timestampMs: now, candidates: [now], error: "" };
if (totpTimeMode === "relative") {
if (!/^-?[0-9]+$/u.test(periodOffset))
throw new RangeError("Enter a whole-number period offset.");
const offset = Number(periodOffset);
return {
timestampMs: shiftTimestampByPeriods(now, profile.period, offset),
candidates: [] as number[],
error: "",
};
}
const resolved = resolveZonedDateTime(
absoluteTime,
timeZone,
timeDisambiguation,
);
return { ...resolved, error: "" };
} catch (reason) {
return {
timestampMs: null,
candidates: [] as number[],
error: timeError(reason),
};
}
}, [
absoluteTime,
now,
periodOffset,
profile.period,
timeDisambiguation,
timeZone,
totpTimeMode,
]);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 500);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
let active = true;
const currentGeneration = ++generationId.current;
if (profile.kind === "totp" && resolvedTime.timestampMs === null)
return undefined;
const generate =
profile.kind === "totp"
? totp({
@@ -106,7 +174,7 @@ export function OtpWorkspace() {
algorithm: profile.algorithm,
digits: profile.digits,
period: profile.period,
timestamp: now / 1000,
timestamp: resolvedTime.timestampMs! / 1000,
})
: hotp({
secret: profile.secret,
@@ -116,18 +184,18 @@ export function OtpWorkspace() {
});
void generate
.then((value) => {
if (active) setCode(value);
if (generationId.current === currentGeneration) setCode(value);
})
.catch((reason: unknown) => {
if (active)
if (generationId.current === currentGeneration)
setError(
reason instanceof Error ? reason.message : "OTP generation failed.",
);
});
return () => {
active = false;
if (generationId.current === currentGeneration) generationId.current += 1;
};
}, [profile, now]);
}, [profile, resolvedTime.timestampMs]);
const uri = useMemo(() => {
try {
@@ -144,10 +212,31 @@ export function OtpWorkspace() {
}
}, [uri]);
const strength = useMemo(() => profileStrength(profile), [profile]);
const remaining =
profile.kind === "totp"
? profile.period - (Math.floor(now / 1000) % profile.period)
: null;
const timeDetails = useMemo(() => {
if (
profile.kind !== "totp" ||
resolvedTime.timestampMs === null ||
!Number.isInteger(profile.period) ||
profile.period < 1
)
return null;
const seconds = Math.floor(resolvedTime.timestampMs / 1000);
try {
return {
local: new Intl.DateTimeFormat(undefined, {
timeZone,
dateStyle: "medium",
timeStyle: "long",
}).format(resolvedTime.timestampMs),
utc: new Date(resolvedTime.timestampMs).toISOString(),
unixSeconds: seconds,
counter: totpCounter(seconds, profile.period).toString(),
remaining: profile.period - (seconds % profile.period),
};
} catch {
return null;
}
}, [profile.kind, profile.period, resolvedTime.timestampMs, timeZone]);
const update = <Key extends keyof OtpProfile>(
key: Key,
@@ -183,6 +272,8 @@ export function OtpWorkspace() {
};
const verify = async () => {
try {
if (profile.kind === "totp" && resolvedTime.timestampMs === null)
throw new Error(resolvedTime.error);
const match =
profile.kind === "totp"
? await verifyTotp(verifyCode, {
@@ -190,7 +281,7 @@ export function OtpWorkspace() {
algorithm: profile.algorithm,
digits: profile.digits,
period: profile.period,
timestamp: now / 1000,
timestamp: resolvedTime.timestampMs! / 1000,
window: 2,
})
: await verifyHotp(verifyCode, {
@@ -211,6 +302,33 @@ export function OtpWorkspace() {
);
}
};
const adjustPeriodOffset = (delta: number) => {
const current = /^-?[0-9]+$/u.test(periodOffset) ? Number(periodOffset) : 0;
const next = Math.max(
-MAX_TOTP_PERIOD_OFFSET,
Math.min(MAX_TOTP_PERIOD_OFFSET, current + delta),
);
setPeriodOffset(next.toString());
};
const selectAbsoluteTime = () => {
try {
setAbsoluteTime(
formatDateTimeLocal(resolvedTime.timestampMs ?? now, timeZone),
);
} catch {
setAbsoluteTime(formatDateTimeLocal(now, "UTC"));
setTimeZone("UTC");
}
setTotpTimeMode("absolute");
};
const setAbsoluteToNow = () => {
try {
setAbsoluteTime(formatDateTimeLocal(now, timeZone));
} catch {
setTimeZone("UTC");
setAbsoluteTime(formatDateTimeLocal(now, "UTC"));
}
};
const performImport = (text = migrationText) => {
try {
const result = detectImport(text);
@@ -439,20 +557,177 @@ export function OtpWorkspace() {
<p className="eyebrow">Live value</p>
<h2>{profile.kind.toUpperCase()} code</h2>
</div>
{remaining !== null && (
<span className="countdown">{remaining}s</span>
{timeDetails !== null && (
<span className="countdown">
{totpTimeMode === "absolute"
? "Frozen"
: `${timeDetails.remaining}s`}
</span>
)}
</div>
<div className="code-display" aria-live="polite">
{code.match(/.{1,3}/gu)?.join(" ")}
</div>
<div className="progress" aria-hidden="true">
{remaining !== null && (
{timeDetails !== null && (
<span
style={{ width: `${(remaining / profile.period) * 100}%` }}
style={{
width: `${(timeDetails.remaining / profile.period) * 100}%`,
}}
/>
)}
</div>
{profile.kind === "totp" && (
<section className="time-travel" aria-label="TOTP time travel">
<div className="time-mode" role="group" aria-label="Time mode">
<button
type="button"
className={totpTimeMode === "current" ? "active" : ""}
aria-pressed={totpTimeMode === "current"}
onClick={() => setTotpTimeMode("current")}
>
Current
</button>
<button
type="button"
className={totpTimeMode === "relative" ? "active" : ""}
aria-pressed={totpTimeMode === "relative"}
onClick={() => setTotpTimeMode("relative")}
>
Period offset
</button>
<button
type="button"
className={totpTimeMode === "absolute" ? "active" : ""}
aria-pressed={totpTimeMode === "absolute"}
onClick={selectAbsoluteTime}
>
Point in time
</button>
</div>
{totpTimeMode === "relative" && (
<div className="time-offset-row">
<button
type="button"
className="secondary-button"
aria-label="Previous TOTP period"
onClick={() => adjustPeriodOffset(-1)}
>
1
</button>
<label>
<span>Period offset (epochs)</span>
<input
type="number"
inputMode="numeric"
min={-MAX_TOTP_PERIOD_OFFSET}
max={MAX_TOTP_PERIOD_OFFSET}
step="1"
value={periodOffset}
onChange={(event) => {
if (event.target.value.length <= 16)
setPeriodOffset(event.target.value);
}}
/>
</label>
<button
type="button"
className="secondary-button"
aria-label="Next TOTP period"
onClick={() => adjustPeriodOffset(1)}
>
+1
</button>
</div>
)}
{totpTimeMode === "absolute" && (
<div className="absolute-time-grid">
<label>
<span>Local date and time</span>
<input
type="datetime-local"
min="1970-01-01T00:00:00"
max="9999-12-31T23:59:59"
step="1"
value={absoluteTime}
onChange={(event) =>
setAbsoluteTime(event.target.value)
}
/>
</label>
<label>
<span>IANA timezone</span>
<input
list="otp-time-zones"
spellCheck={false}
maxLength={100}
value={timeZone}
onChange={(event) => setTimeZone(event.target.value)}
/>
<datalist id="otp-time-zones">
{timeZones.map((zone) => (
<option value={zone} key={zone} />
))}
</datalist>
</label>
<button
type="button"
className="secondary-button compact-button"
onClick={setAbsoluteToNow}
>
Set to now in this timezone
</button>
{resolvedTime.candidates.length > 1 && (
<label>
<span>Repeated clock time</span>
<select
value={timeDisambiguation}
onChange={(event) =>
setTimeDisambiguation(
event.target.value as TimeDisambiguation,
)
}
>
<option value="earlier">Earlier occurrence</option>
<option value="later">Later occurrence</option>
</select>
</label>
)}
</div>
)}
{resolvedTime.error ? (
<p className="inline-error" role="alert">
{resolvedTime.error} The last valid code remains visible.
</p>
) : (
timeDetails && (
<dl className="time-readout">
<div>
<dt>Effective time</dt>
<dd>{timeDetails.local}</dd>
</div>
<div>
<dt>UTC instant</dt>
<dd>{timeDetails.utc}</dd>
</div>
<div>
<dt>Unix seconds</dt>
<dd>{timeDetails.unixSeconds}</dd>
</div>
<div>
<dt>TOTP counter</dt>
<dd>{timeDetails.counter}</dd>
</div>
</dl>
)
)}
<p className="hint">
One epoch is the configured {profile.period}-second period.
Timezones interpret wall-clock input; the TOTP calculation
always uses the resulting UTC instant.
</p>
</section>
)}
<div className="verify-row">
<input
aria-label="Code to verify"
+205
View File
@@ -0,0 +1,205 @@
export const MAX_TOTP_PERIOD_OFFSET = 10_000_000;
export type TimeDisambiguation = "earlier" | "later";
export interface LocalDateTimeFields {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
millisecond: number;
}
const formatterCache = new Map<string, Intl.DateTimeFormat>();
function formatter(timeZone: string): Intl.DateTimeFormat {
const normalized = timeZone.trim();
if (!normalized || normalized.length > 100)
throw new RangeError("Enter a valid IANA timezone.");
const existing = formatterCache.get(normalized);
if (existing) return existing;
let created: Intl.DateTimeFormat;
try {
created = new Intl.DateTimeFormat("en-GB-u-ca-gregory-nu-latn", {
timeZone: normalized,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
hourCycle: "h23",
});
} catch {
throw new RangeError(`Unknown IANA timezone: ${normalized}`);
}
formatterCache.set(normalized, created);
return created;
}
function utcTimestamp(fields: LocalDateTimeFields): number {
const date = new Date(0);
date.setUTCFullYear(fields.year, fields.month - 1, fields.day);
date.setUTCHours(
fields.hour,
fields.minute,
fields.second,
fields.millisecond,
);
return date.getTime();
}
function fieldsAt(timestampMs: number, timeZone: string): LocalDateTimeFields {
const values = new Map(
formatter(timeZone)
.formatToParts(new Date(timestampMs))
.map((part) => [part.type, part.value]),
);
return {
year: Number(values.get("year")),
month: Number(values.get("month")),
day: Number(values.get("day")),
hour: Number(values.get("hour")),
minute: Number(values.get("minute")),
second: Number(values.get("second")),
millisecond: Number(values.get("fractionalSecond") ?? "0"),
};
}
function sameFields(
left: LocalDateTimeFields,
right: LocalDateTimeFields,
): boolean {
return (
left.year === right.year &&
left.month === right.month &&
left.day === right.day &&
left.hour === right.hour &&
left.minute === right.minute &&
left.second === right.second &&
left.millisecond === right.millisecond
);
}
export function parseLocalDateTime(value: string): LocalDateTimeFields {
const match =
/^(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})T(?<hour>[0-9]{2}):(?<minute>[0-9]{2})(?::(?<second>[0-9]{2})(?:\.(?<fraction>[0-9]{1,3}))?)?$/u.exec(
value,
);
if (!match?.groups)
throw new RangeError("Enter a complete local date and time.");
const fields = {
year: Number(match.groups.year),
month: Number(match.groups.month),
day: Number(match.groups.day),
hour: Number(match.groups.hour),
minute: Number(match.groups.minute),
second: Number(match.groups.second ?? "0"),
millisecond: Number((match.groups.fraction ?? "0").padEnd(3, "0")),
};
if (fields.year < 1970 || fields.year > 9999)
throw new RangeError("TOTP time must be between 1970 and 9999.");
const timestamp = utcTimestamp(fields);
if (
!Number.isFinite(timestamp) ||
!sameFields(fieldsAt(timestamp, "UTC"), fields)
)
throw new RangeError("Enter a valid calendar date and time.");
return fields;
}
export function zonedDateTimeCandidates(
value: string,
timeZone: string,
): number[] {
const desired = parseLocalDateTime(value);
const wallTimestamp = utcTimestamp(desired);
const offsets = new Set<number>();
for (let hours = -48; hours <= 48; hours += 6) {
const sample = wallTimestamp + hours * 60 * 60 * 1000;
const roundedSample = Math.floor(sample / 1000) * 1000;
offsets.add(
utcTimestamp(fieldsAt(roundedSample, timeZone)) - roundedSample,
);
}
const candidates = [...offsets]
.map((offset) => wallTimestamp - offset)
.filter(
(candidate) =>
candidate >= 0 && sameFields(fieldsAt(candidate, timeZone), desired),
);
return [...new Set(candidates)].sort((left, right) => left - right);
}
export function resolveZonedDateTime(
value: string,
timeZone: string,
disambiguation: TimeDisambiguation = "earlier",
): { timestampMs: number; candidates: number[] } {
const candidates = zonedDateTimeCandidates(value, timeZone);
if (!candidates.length) {
throw new RangeError(
"That local time does not exist in this timezone because of a clock change.",
);
}
return {
timestampMs:
disambiguation === "later" ? candidates.at(-1)! : candidates.at(0)!,
candidates,
};
}
function pad(value: number, length = 2): string {
return value.toString().padStart(length, "0");
}
export function formatDateTimeLocal(
timestampMs: number,
timeZone: string,
): string {
if (!Number.isFinite(timestampMs) || timestampMs < 0)
throw new RangeError("TOTP timestamp must be non-negative.");
const fields = fieldsAt(timestampMs, timeZone);
return `${pad(fields.year, 4)}-${pad(fields.month)}-${pad(fields.day)}T${pad(fields.hour)}:${pad(fields.minute)}:${pad(fields.second)}`;
}
export function shiftTimestampByPeriods(
timestampMs: number,
periodSeconds: number,
periodOffset: number,
): number {
if (!Number.isFinite(timestampMs) || timestampMs < 0)
throw new RangeError("TOTP timestamp must be non-negative.");
if (
!Number.isInteger(periodSeconds) ||
periodSeconds < 1 ||
periodSeconds > 86_400
)
throw new RangeError("TOTP period must be between 1 and 86400 seconds.");
if (
!Number.isSafeInteger(periodOffset) ||
Math.abs(periodOffset) > MAX_TOTP_PERIOD_OFFSET
)
throw new RangeError(
`Period offset must be between -${MAX_TOTP_PERIOD_OFFSET} and ${MAX_TOTP_PERIOD_OFFSET}.`,
);
const shifted = timestampMs + periodOffset * periodSeconds * 1000;
if (!Number.isSafeInteger(shifted) || shifted < 0)
throw new RangeError(
"The selected period offset is outside supported time.",
);
return shifted;
}
export function supportedTimeZones(): string[] {
const implementation = Intl as typeof Intl & {
supportedValuesOf?: (key: "timeZone") => string[];
};
const zones = implementation.supportedValuesOf?.("timeZone") ?? [];
return [...new Set(["UTC", ...zones])].sort((left, right) =>
left.localeCompare(right),
);
}
+80
View File
@@ -386,6 +386,81 @@ button:disabled {
background: var(--auth-blue);
transition: width 0.4s linear;
}
.time-travel {
display: grid;
gap: 0.75rem;
margin: 0 1rem 1rem;
padding: 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.75);
background: var(--toolbox-surface-soft);
}
.time-mode {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.25rem;
padding: 0.2rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.6);
background: var(--toolbox-background);
}
.time-mode button {
min-width: 0;
border: 0;
border-radius: calc(var(--toolbox-radius) * 0.45);
padding: 0.45rem 0.35rem;
background: transparent;
color: var(--toolbox-muted);
font-size: 0.72rem;
font-weight: 750;
}
.time-mode button.active {
background: var(--toolbox-surface);
color: var(--toolbox-text);
box-shadow: 0 1px 5px rgb(20 30 60 / 10%);
}
.time-offset-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.45rem;
align-items: end;
}
.absolute-time-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.6rem;
align-items: end;
}
.time-readout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
margin: 0;
}
.time-readout div {
min-width: 0;
padding: 0.5rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.5);
background: var(--toolbox-surface);
}
.time-readout dt {
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 750;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.time-readout dd {
margin: 0.18rem 0 0;
overflow-wrap: anywhere;
color: var(--toolbox-text);
font:
650 0.72rem/1.35 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
}
.verify-row {
padding: 0 1rem;
}
@@ -757,6 +832,11 @@ button:disabled {
.input-actions input {
flex-basis: 100%;
}
.time-mode,
.absolute-time-grid,
.time-readout {
grid-template-columns: 1fr;
}
.workspace-tabs small {
display: none;
}
+49 -1
View File
@@ -1,4 +1,10 @@
import { render, screen } from "@testing-library/react";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Workbench } from "../../src/components/Workbench";
@@ -38,4 +44,46 @@ describe("authentication workbench", () => {
screen.getByRole("button", { name: "Create test credential" }),
).toBeDisabled();
});
it("generates a TOTP at an absolute wall-clock time in an explicit timezone", async () => {
const user = userEvent.setup();
render(<Workbench />);
fireEvent.change(screen.getByLabelText("Base32 secret"), {
target: { value: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" },
});
fireEvent.change(screen.getByLabelText("Account"), {
target: { value: "rfc-vector" },
});
await user.click(screen.getByRole("button", { name: "Show secret" }));
await waitFor(() =>
expect(
(screen.getByLabelText("Provisioning URI") as HTMLTextAreaElement)
.value,
).toContain("secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"),
);
await user.selectOptions(screen.getByLabelText("Digits"), "8");
await user.click(screen.getByRole("button", { name: "Point in time" }));
fireEvent.change(screen.getByLabelText("Local date and time"), {
target: { value: "1970-01-01T00:00:59" },
});
fireEvent.change(screen.getByLabelText("IANA timezone"), {
target: { value: "UTC" },
});
expect(screen.getByLabelText("Local date and time")).toHaveValue(
"1970-01-01T00:00:59.000",
);
expect(screen.getByLabelText("IANA timezone")).toHaveValue("UTC");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText("1970-01-01T00:00:59.000Z")).toBeInTheDocument();
expect(
within(screen.getByText("TOTP counter").parentElement!).getByText("1"),
).toBeInTheDocument();
await waitFor(() =>
expect(document.querySelector(".code-display")).toHaveTextContent(
"942 870 82",
),
);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
formatDateTimeLocal,
parseLocalDateTime,
resolveZonedDateTime,
shiftTimestampByPeriods,
zonedDateTimeCandidates,
} from "../../src/otp/time";
describe("TOTP time travel", () => {
it("parses exact UTC wall-clock values", () => {
const resolved = resolveZonedDateTime("2025-08-19T12:34:56", "UTC");
expect(new Date(resolved.timestampMs).toISOString()).toBe(
"2025-08-19T12:34:56.000Z",
);
expect(formatDateTimeLocal(resolved.timestampMs, "UTC")).toBe(
"2025-08-19T12:34:56",
);
expect(
resolveZonedDateTime("2025-08-19T12:34:56.125", "UTC").timestampMs,
).toBe(Date.UTC(2025, 7, 19, 12, 34, 56, 125));
});
it("respects seasonal IANA timezone offsets", () => {
expect(
new Date(
resolveZonedDateTime("2024-01-15T12:00:00", "America/New_York")
.timestampMs,
).toISOString(),
).toBe("2024-01-15T17:00:00.000Z");
expect(
new Date(
resolveZonedDateTime("2024-07-15T12:00:00", "America/New_York")
.timestampMs,
).toISOString(),
).toBe("2024-07-15T16:00:00.000Z");
});
it("rejects nonexistent DST times and exposes both repeated times", () => {
expect(() =>
resolveZonedDateTime("2024-03-10T02:30:00", "America/New_York"),
).toThrow(/does not exist/iu);
const candidates = zonedDateTimeCandidates(
"2024-11-03T01:30:00",
"America/New_York",
);
expect(candidates.map((value) => new Date(value).toISOString())).toEqual([
"2024-11-03T05:30:00.000Z",
"2024-11-03T06:30:00.000Z",
]);
expect(
new Date(
resolveZonedDateTime("2024-11-03T01:30:00", "America/New_York", "later")
.timestampMs,
).toISOString(),
).toBe("2024-11-03T06:30:00.000Z");
});
it("handles non-hour timezone offsets", () => {
expect(
new Date(
resolveZonedDateTime("2025-08-19T12:00:00", "Asia/Kathmandu")
.timestampMs,
).toISOString(),
).toBe("2025-08-19T06:15:00.000Z");
});
it("validates calendar values, timezone names and bounded period shifts", () => {
expect(() => parseLocalDateTime("2025-02-29T12:00:00")).toThrow(
/valid calendar/iu,
);
expect(() =>
resolveZonedDateTime("2025-01-01T00:00:00", "Mars/Base"),
).toThrow(/timezone/iu);
expect(shiftTimestampByPeriods(90_000, 30, -2)).toBe(30_000);
expect(() => shiftTimestampByPeriods(1_000, 30, -1)).toThrow(
/outside supported time/iu,
);
});
});