feat: add timezone-aware TOTP time travel
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user