feat: add timezone-aware TOTP time travel
This commit is contained in:
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
All notable changes are documented here.
|
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
|
## 0.1.0 - 2026-08-19
|
||||||
|
|
||||||
- Initial HOTP, TOTP and OCRA credential laboratory with official RFC-vector coverage.
|
- 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.
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Explicit URI-list and CSV export with an unencrypted-secret warning.
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
All notable changes are documented here.
|
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
|
## 0.1.0 - 2026-08-19
|
||||||
|
|
||||||
- Initial HOTP, TOTP and OCRA credential laboratory with official RFC-vector coverage.
|
- 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.
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 {
|
import {
|
||||||
base32ToBytes,
|
base32ToBytes,
|
||||||
bytesToBase32,
|
bytesToBase32,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
hotp,
|
hotp,
|
||||||
totp,
|
totp,
|
||||||
|
totpCounter,
|
||||||
verifyHotp,
|
verifyHotp,
|
||||||
verifyTotp,
|
verifyTotp,
|
||||||
type OtpHashAlgorithm,
|
type OtpHashAlgorithm,
|
||||||
@@ -27,8 +28,26 @@ import {
|
|||||||
serializeOtpAuth,
|
serializeOtpAuth,
|
||||||
type OtpProfile,
|
type OtpProfile,
|
||||||
} from "../../otp/profile";
|
} from "../../otp/profile";
|
||||||
|
import {
|
||||||
|
formatDateTimeLocal,
|
||||||
|
MAX_TOTP_PERIOD_OFFSET,
|
||||||
|
resolveZonedDateTime,
|
||||||
|
shiftTimestampByPeriods,
|
||||||
|
supportedTimeZones,
|
||||||
|
type TimeDisambiguation,
|
||||||
|
} from "../../otp/time";
|
||||||
import { qrSvg } from "../../qr/encoder";
|
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(
|
function downloadText(
|
||||||
name: string,
|
name: string,
|
||||||
content: string,
|
content: string,
|
||||||
@@ -82,7 +101,16 @@ export function OtpWorkspace() {
|
|||||||
);
|
);
|
||||||
const [secretVisible, setSecretVisible] = useState(false);
|
const [secretVisible, setSecretVisible] = useState(false);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
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 [code, setCode] = useState("······");
|
||||||
|
const generationId = useRef(0);
|
||||||
const [verifyCode, setVerifyCode] = useState("");
|
const [verifyCode, setVerifyCode] = useState("");
|
||||||
const [verification, setVerification] = useState("");
|
const [verification, setVerification] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -93,12 +121,52 @@ export function OtpWorkspace() {
|
|||||||
const [imported, setImported] = useState<OtpProfile[]>([]);
|
const [imported, setImported] = useState<OtpProfile[]>([]);
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => setNow(Date.now()), 500);
|
const timer = window.setInterval(() => setNow(Date.now()), 500);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
const currentGeneration = ++generationId.current;
|
||||||
|
if (profile.kind === "totp" && resolvedTime.timestampMs === null)
|
||||||
|
return undefined;
|
||||||
const generate =
|
const generate =
|
||||||
profile.kind === "totp"
|
profile.kind === "totp"
|
||||||
? totp({
|
? totp({
|
||||||
@@ -106,7 +174,7 @@ export function OtpWorkspace() {
|
|||||||
algorithm: profile.algorithm,
|
algorithm: profile.algorithm,
|
||||||
digits: profile.digits,
|
digits: profile.digits,
|
||||||
period: profile.period,
|
period: profile.period,
|
||||||
timestamp: now / 1000,
|
timestamp: resolvedTime.timestampMs! / 1000,
|
||||||
})
|
})
|
||||||
: hotp({
|
: hotp({
|
||||||
secret: profile.secret,
|
secret: profile.secret,
|
||||||
@@ -116,18 +184,18 @@ export function OtpWorkspace() {
|
|||||||
});
|
});
|
||||||
void generate
|
void generate
|
||||||
.then((value) => {
|
.then((value) => {
|
||||||
if (active) setCode(value);
|
if (generationId.current === currentGeneration) setCode(value);
|
||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
if (active)
|
if (generationId.current === currentGeneration)
|
||||||
setError(
|
setError(
|
||||||
reason instanceof Error ? reason.message : "OTP generation failed.",
|
reason instanceof Error ? reason.message : "OTP generation failed.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
if (generationId.current === currentGeneration) generationId.current += 1;
|
||||||
};
|
};
|
||||||
}, [profile, now]);
|
}, [profile, resolvedTime.timestampMs]);
|
||||||
|
|
||||||
const uri = useMemo(() => {
|
const uri = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -144,10 +212,31 @@ export function OtpWorkspace() {
|
|||||||
}
|
}
|
||||||
}, [uri]);
|
}, [uri]);
|
||||||
const strength = useMemo(() => profileStrength(profile), [profile]);
|
const strength = useMemo(() => profileStrength(profile), [profile]);
|
||||||
const remaining =
|
const timeDetails = useMemo(() => {
|
||||||
profile.kind === "totp"
|
if (
|
||||||
? profile.period - (Math.floor(now / 1000) % profile.period)
|
profile.kind !== "totp" ||
|
||||||
: null;
|
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>(
|
const update = <Key extends keyof OtpProfile>(
|
||||||
key: Key,
|
key: Key,
|
||||||
@@ -183,6 +272,8 @@ export function OtpWorkspace() {
|
|||||||
};
|
};
|
||||||
const verify = async () => {
|
const verify = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (profile.kind === "totp" && resolvedTime.timestampMs === null)
|
||||||
|
throw new Error(resolvedTime.error);
|
||||||
const match =
|
const match =
|
||||||
profile.kind === "totp"
|
profile.kind === "totp"
|
||||||
? await verifyTotp(verifyCode, {
|
? await verifyTotp(verifyCode, {
|
||||||
@@ -190,7 +281,7 @@ export function OtpWorkspace() {
|
|||||||
algorithm: profile.algorithm,
|
algorithm: profile.algorithm,
|
||||||
digits: profile.digits,
|
digits: profile.digits,
|
||||||
period: profile.period,
|
period: profile.period,
|
||||||
timestamp: now / 1000,
|
timestamp: resolvedTime.timestampMs! / 1000,
|
||||||
window: 2,
|
window: 2,
|
||||||
})
|
})
|
||||||
: await verifyHotp(verifyCode, {
|
: 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) => {
|
const performImport = (text = migrationText) => {
|
||||||
try {
|
try {
|
||||||
const result = detectImport(text);
|
const result = detectImport(text);
|
||||||
@@ -439,20 +557,177 @@ export function OtpWorkspace() {
|
|||||||
<p className="eyebrow">Live value</p>
|
<p className="eyebrow">Live value</p>
|
||||||
<h2>{profile.kind.toUpperCase()} code</h2>
|
<h2>{profile.kind.toUpperCase()} code</h2>
|
||||||
</div>
|
</div>
|
||||||
{remaining !== null && (
|
{timeDetails !== null && (
|
||||||
<span className="countdown">{remaining}s</span>
|
<span className="countdown">
|
||||||
|
{totpTimeMode === "absolute"
|
||||||
|
? "Frozen"
|
||||||
|
: `${timeDetails.remaining}s`}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="code-display" aria-live="polite">
|
<div className="code-display" aria-live="polite">
|
||||||
{code.match(/.{1,3}/gu)?.join(" ")}
|
{code.match(/.{1,3}/gu)?.join(" ")}
|
||||||
</div>
|
</div>
|
||||||
<div className="progress" aria-hidden="true">
|
<div className="progress" aria-hidden="true">
|
||||||
{remaining !== null && (
|
{timeDetails !== null && (
|
||||||
<span
|
<span
|
||||||
style={{ width: `${(remaining / profile.period) * 100}%` }}
|
style={{
|
||||||
|
width: `${(timeDetails.remaining / profile.period) * 100}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="verify-row">
|
||||||
<input
|
<input
|
||||||
aria-label="Code to verify"
|
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);
|
background: var(--auth-blue);
|
||||||
transition: width 0.4s linear;
|
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 {
|
.verify-row {
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
@@ -757,6 +832,11 @@ button:disabled {
|
|||||||
.input-actions input {
|
.input-actions input {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
|
.time-mode,
|
||||||
|
.absolute-time-grid,
|
||||||
|
.time-readout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.workspace-tabs small {
|
.workspace-tabs small {
|
||||||
display: none;
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { Workbench } from "../../src/components/Workbench";
|
import { Workbench } from "../../src/components/Workbench";
|
||||||
@@ -38,4 +44,46 @@ describe("authentication workbench", () => {
|
|||||||
screen.getByRole("button", { name: "Create test credential" }),
|
screen.getByRole("button", { name: "Create test credential" }),
|
||||||
).toBeDisabled();
|
).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