/create seals a person's file into a .dkc of format 2 and, when asked, a portable .dkk, in the browser and without network, with the decisions the author confirmed in step 6: time_only by default, the zone of the device with a selector, the warnings of §53 and §50 beyond 365 days, a notice of preliminary protocol, and files named capsula-<opening time, UTC>. - lengths.ts: sealedControlLength moves out of writer.ts, and capsuleLength gives the size of the .dkc before writing it; the property loop checks it on every capsule (500 seeds pass). - datekey.ts: LONG_HORIZON_SECONDS and isLongHorizon. - src/lib/inspector: localtime.ts (local times of a zone as UTC instants, a skipped time refused, a repeated one taken at its later instant), create-input.ts (the form, checked in its order) and creator.ts (the writing, loaded on demand), all at 100 %. - The .dkc goes to an OPFS temporary file, committed only when complete and checked, or to memory up to 64 MiB; the writing can be cancelled. The .dkk stays in memory only; losing it when it is the only credential asks for confirmation. - /inspect also cleans the temporary files of /create, and check-build checks the code loaded on demand of both pages. Checked in the browser on the production build: a capsule made for four minutes later opened afterwards in /inspect with the pasted release and with datekeys decrypt of Go over the network, to the same content. An adversarial review found one major and eight minor issues, all fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
da0b39ff8c
commit
3a9d2b11fe
@ -0,0 +1,97 @@
|
||||
// Tests of lengths.ts: the size of a .dkc of format 2 before writing it,
|
||||
// against the format 2 fixtures of the Go reference and against what encrypt
|
||||
// writes. The property loop (encrypt.property.test.ts) checks it on every
|
||||
// capsule it writes.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeControl } from './control.ts';
|
||||
import { parseRFC3339 } from './datekey.ts';
|
||||
import { encrypt } from './encrypt.ts';
|
||||
import { FORMAT_2 } from './framing.ts';
|
||||
import { decodeHeader, TIME_AND_KEY, TIME_ONLY } from './header.ts';
|
||||
import { capsuleLength, sealedControlLength } from './lengths.ts';
|
||||
import { BLOQUE256, MAX_PAYLOAD_LENGTH, REFORZADO } from './padding.ts';
|
||||
import { QUICKNET_ID, quicknet } from './profile.ts';
|
||||
import { h, listTestdata, readBytes, readJSON } from './testing/testdata.ts';
|
||||
import { newX25519Identity, x25519PublicKey } from './x25519.ts';
|
||||
|
||||
const GENESIS = parseRFC3339('2023-08-23T15:09:27Z');
|
||||
const roundAt = (r: number) => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 });
|
||||
|
||||
describe('sealedControlLength', () => {
|
||||
it('gives the lengths of spec §62.1 for a control of 103 bytes: 458 and 2128 at round 1000', () => {
|
||||
expect(sealedControlLength(TIME_ONLY, 103, 1000)).toBe(458);
|
||||
expect(sealedControlLength(TIME_AND_KEY, 103, 1000)).toBe(2128);
|
||||
});
|
||||
|
||||
it('counts the digits of the round and a tag of 16 bytes per chunk of 64 KiB', () => {
|
||||
expect(sealedControlLength(TIME_ONLY, 103, 83_903_165_811) - sealedControlLength(TIME_ONLY, 103, 1)).toBe(10);
|
||||
expect(sealedControlLength(TIME_ONLY, 65537, 1000) - sealedControlLength(TIME_ONLY, 65536, 1000)).toBe(1 + 16);
|
||||
// INNER_ACCESS_AGE of a control of 63 866 bytes is 65 536 bytes, one
|
||||
// chunk of OUTER_TIME_AGE; one byte more takes two.
|
||||
expect(sealedControlLength(TIME_AND_KEY, 63_867, 1000) - sealedControlLength(TIME_AND_KEY, 63_866, 1000)).toBe(1 + 16);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capsuleLength', () => {
|
||||
const records = listTestdata('fixtures', '.json')
|
||||
.filter((f) => /\/format2_[^/]*\.json$/.test(f) && !f.endsWith('.inspect.json') && !f.endsWith('.dkk.json'))
|
||||
.map((f) => readJSON<{ file: string; public_header: string; control_cbor: string }>(f));
|
||||
|
||||
it('gives the size of each format 2 fixture of the Go reference from its header and its control', () => {
|
||||
expect(records).toHaveLength(7);
|
||||
for (const r of records) {
|
||||
const header = decodeHeader(h(r.public_header));
|
||||
const control = decodeControl(h(r.control_cbor), FORMAT_2);
|
||||
const size = capsuleLength({
|
||||
profileId: header.dateKey.profileId,
|
||||
round: header.dateKey.round,
|
||||
policy: header.policy,
|
||||
length: control.payloadLength!,
|
||||
padding: control.padding!,
|
||||
critical: header.critical,
|
||||
noncritical: header.noncritical,
|
||||
controlCritical: control.critical,
|
||||
controlNoncritical: control.noncritical,
|
||||
});
|
||||
expect(size, r.file).toBe(readBytes(`fixtures/${r.file}`).length);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives the size of what encrypt writes, with its defaults: reforzado and no extensions', async () => {
|
||||
const cases = [
|
||||
{ policy: TIME_ONLY, round: 1, length: 0 },
|
||||
{ policy: TIME_ONLY, round: 1000, length: 65_536 },
|
||||
{ policy: TIME_ONLY, round: 1000, length: 78_000 },
|
||||
{ policy: TIME_AND_KEY, round: 1001, length: 41 },
|
||||
{ policy: TIME_AND_KEY, round: 83_903_165_811, length: 300_000 },
|
||||
];
|
||||
for (const c of cases) {
|
||||
const keyed = c.policy === TIME_AND_KEY;
|
||||
const res = await encrypt(new Uint8Array(c.length), {
|
||||
profile: quicknet(),
|
||||
unlockAt: roundAt(c.round),
|
||||
policy: c.policy,
|
||||
now: () => ({ seconds: GENESIS.seconds - 3600, nanos: 0 }),
|
||||
...(keyed ? { recipients: [x25519PublicKey(newX25519Identity())], newPortableKey: true } : {}),
|
||||
});
|
||||
expect(res.dkc!.length).toBe(res.size);
|
||||
expect(capsuleLength({ profileId: QUICKNET_ID, round: c.round, policy: c.policy, length: c.length }), JSON.stringify(c)).toBe(res.size);
|
||||
}
|
||||
});
|
||||
|
||||
it('follows the padding rule, reforzado by default, which pads some L past 8 KiB more than bloque256', () => {
|
||||
const at = { profileId: QUICKNET_ID, round: 1000, policy: TIME_ONLY };
|
||||
expect(capsuleLength({ ...at, length: 70_000 })).toBe(capsuleLength({ ...at, length: 70_000, padding: REFORZADO }));
|
||||
expect(capsuleLength({ ...at, length: 8192, padding: REFORZADO })).toBe(capsuleLength({ ...at, length: 8192, padding: BLOQUE256 }));
|
||||
// P = 71 680 with reforzado and 70 144 with bloque256, both in two chunks.
|
||||
expect(capsuleLength({ ...at, length: 70_000, padding: REFORZADO }) - capsuleLength({ ...at, length: 70_000, padding: BLOQUE256 })).toBe(1536);
|
||||
});
|
||||
|
||||
it('throws, as the encoders do, for an invalid DateKey, policy or L', () => {
|
||||
const at = { profileId: QUICKNET_ID, round: 1000, policy: TIME_ONLY, length: 1 };
|
||||
expect(() => capsuleLength({ ...at, round: 0 })).toThrow('capsule: invalid DateKey');
|
||||
expect(() => capsuleLength({ ...at, policy: 7 })).toThrow('capsule: unknown access policy 7');
|
||||
expect(() => capsuleLength({ ...at, length: MAX_PAYLOAD_LENGTH + 1 })).toThrow(/^capsule: payload_length \d+ outside 0\.\.L_MAX/);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,78 @@
|
||||
// The lengths of a .dkc of format 2 that follow from its inputs, without
|
||||
// writing it (spec §62.1, informative note, and §29.1): what a page shows
|
||||
// before encrypting, since the size is visible to anyone who holds the file
|
||||
// (§55.2), and what the writer checks its seal against. No noble and no
|
||||
// age-encryption, so that a page can load it with its first load.
|
||||
|
||||
import { ACCESS_SLOTS } from './age.ts';
|
||||
import { encodeControl } from './control.ts';
|
||||
import type { Extension } from './extension.ts';
|
||||
import { DKC_PRELUDE_SIZE, FORMAT_2 } from './framing.ts';
|
||||
import { CAPSULE_ID_SIZE, encodeHeader, type Policy, TIME_AND_KEY } from './header.ts';
|
||||
import { paddedLength, type Padding, payloadAgeLength, REFORZADO } from './padding.ts';
|
||||
|
||||
// The chunks of an age STREAM of n bytes: at least one, even when empty.
|
||||
const chunks = (n: number): number => Math.max(1, Math.ceil(n / 65536));
|
||||
|
||||
/**
|
||||
* SEALED_CONTROL_LEN from the lengths of spec §62.1 (informative note), with
|
||||
* C the length of CONTROL_CBOR: INNER_ACCESS_AGE holds 16 X25519 stanzas of
|
||||
* 98 bytes, and the tlock stanza of OUTER_TIME_AGE is 249 bytes plus the
|
||||
* digits of the round. The writer checks that the real seal measures exactly
|
||||
* this.
|
||||
*/
|
||||
export function sealedControlLength(policy: Policy, controlLength: number, round: number): number {
|
||||
const n = policy === TIME_AND_KEY ? 86 + 98 * ACCESS_SLOTS + controlLength + 16 * chunks(controlLength) : controlLength;
|
||||
return 335 + String(round).length + n + 16 * chunks(n);
|
||||
}
|
||||
|
||||
/** What the size of a .dkc depends on: every input of encrypt but the content and the credentials. */
|
||||
export interface CapsuleLengthInput {
|
||||
/** The profile_id of the DateKey, datekeys:quicknet:v1 for Quicknet. */
|
||||
readonly profileId: string;
|
||||
readonly round: number;
|
||||
readonly policy: Policy;
|
||||
/** L, the length of the content. */
|
||||
readonly length: number;
|
||||
/** The padding rule; reforzado when omitted, as in encrypt. */
|
||||
readonly padding?: Padding;
|
||||
readonly critical?: readonly Extension[];
|
||||
readonly noncritical?: readonly Extension[];
|
||||
readonly controlCritical?: readonly Extension[];
|
||||
readonly controlNoncritical?: readonly Extension[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact size of the .dkc that encrypt writes for these inputs: PRELUDE,
|
||||
* PUBLIC_HEADER, SEALED_CONTROL_LEN and the length of PAYLOAD_AGE for P =
|
||||
* rule(L). The random values and the credentials do not change it: a
|
||||
* time_and_key capsule always holds 16 stanzas (§39). It throws, as the
|
||||
* encoders do, for an invalid DateKey, policy, extension or L.
|
||||
*/
|
||||
export function capsuleLength(input: CapsuleLengthInput): number {
|
||||
const padding = input.padding ?? REFORZADO;
|
||||
const header = encodeHeader({
|
||||
capsuleId: new Uint8Array(CAPSULE_ID_SIZE),
|
||||
dateKey: { profileId: input.profileId, round: input.round },
|
||||
policy: input.policy,
|
||||
critical: input.critical ?? [],
|
||||
noncritical: input.noncritical ?? [],
|
||||
});
|
||||
const control = encodeControl(
|
||||
{
|
||||
headerBinding: new Uint8Array(32),
|
||||
payloadIdentity: new Uint8Array(32),
|
||||
payloadLength: input.length,
|
||||
padding,
|
||||
critical: input.controlCritical ?? [],
|
||||
noncritical: input.controlNoncritical ?? [],
|
||||
},
|
||||
FORMAT_2,
|
||||
);
|
||||
return (
|
||||
DKC_PRELUDE_SIZE +
|
||||
header.length +
|
||||
sealedControlLength(input.policy, control.length, input.round) +
|
||||
payloadAgeLength(paddedLength(input.length, padding))
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
// The form of the create page (plan of phase 3, section 9, as decided in
|
||||
// step 6), without DOM, clock or writer: what the person asked for, checked
|
||||
// field by field in the order of the form, and everything the page shows
|
||||
// before encrypting. Its imports carry no noble and no age-encryption, so the
|
||||
// page loads it with its first load and checks the recipients as they are
|
||||
// typed.
|
||||
|
||||
import { ACCESS_SLOTS } from '../dkc/age.ts';
|
||||
import { compactDateKey, type DateKey, type Instant, isLongHorizon, resolveDateKey, roundTime } from '../dkc/datekey.ts';
|
||||
import { type Policy, TIME_AND_KEY } from '../dkc/header.ts';
|
||||
import { capsuleLength } from '../dkc/lengths.ts';
|
||||
import { MAX_PAYLOAD_LENGTH, paddedLength, REFORZADO } from '../dkc/padding.ts';
|
||||
import { quicknet } from '../dkc/profile.ts';
|
||||
import { parseRecipientList, type RecipientLineProblem, RecipientListError } from '../dkc/recipient.ts';
|
||||
import { formatByteCount, formatInteger, safeFileName } from './format.ts';
|
||||
import { localToEpochMs } from './localtime.ts';
|
||||
|
||||
/** An effective unlock time closer than this gets a notice: the capsule opens almost at once. */
|
||||
export const SOON_MS = 3600_000;
|
||||
|
||||
/** The inputs of the form. */
|
||||
export type CreateField = 'file' | 'date' | 'time' | 'zone' | 'recipients' | 'portable';
|
||||
|
||||
/** What the person entered. */
|
||||
export interface CreateInput {
|
||||
/** L, the size of the chosen file; undefined while there is none. */
|
||||
readonly fileSize: number | undefined;
|
||||
/** The values of <input type="date"> and <input type="time">. */
|
||||
readonly date: string;
|
||||
readonly time: string;
|
||||
/** The zone of the date and time, an IANA name or UTC. */
|
||||
readonly timeZone: string;
|
||||
/** TIME_ONLY or TIME_AND_KEY. */
|
||||
readonly policy: Policy;
|
||||
/** For time_and_key: the recipients, age1… one per line, and whether to generate a portable .dkk. */
|
||||
readonly recipients: string;
|
||||
readonly portable: boolean;
|
||||
}
|
||||
|
||||
/** Everything the page shows before encrypting, and what encrypt takes. */
|
||||
export interface CapsulePlan {
|
||||
/** The requested instant, the one the person picked. */
|
||||
readonly requested: Instant;
|
||||
readonly requestedMs: number;
|
||||
/** The zone repeats the local time, and the later of its two instants was taken. */
|
||||
readonly ambiguous: boolean;
|
||||
readonly dateKey: DateKey;
|
||||
/** The DateKey as the dk1_ string (§17). */
|
||||
readonly dk1: string;
|
||||
/** The effective unlock time: the time of the round, at or after the requested instant (§15). */
|
||||
readonly effective: Instant;
|
||||
readonly effectiveMs: number;
|
||||
readonly policy: Policy;
|
||||
/** The raw X25519 public keys of the recipients. */
|
||||
readonly recipients: readonly Uint8Array[];
|
||||
readonly portable: boolean;
|
||||
/** L, and P = reforzado(L), the rule the page always uses. */
|
||||
readonly length: number;
|
||||
readonly paddedLength: number;
|
||||
/** The size of the .dkc. */
|
||||
readonly size: number;
|
||||
/** The effective time is more than 365 days away: the warnings of §53 and §50. */
|
||||
readonly longHorizon: boolean;
|
||||
/** The effective time is less than SOON_MS away. */
|
||||
readonly soon: boolean;
|
||||
/** The names offered for the files. */
|
||||
readonly names: { readonly dkc: string; readonly dkk: string };
|
||||
}
|
||||
|
||||
export type Planned =
|
||||
| { readonly ok: true; readonly plan: CapsulePlan }
|
||||
| { readonly ok: false; readonly field: CreateField; readonly problem: string };
|
||||
|
||||
const LINE_PROBLEMS: Readonly<Record<RecipientLineProblem, string>> = {
|
||||
malformed: 'no es un destinatario de age (age1…).',
|
||||
identity: 'es una identidad secreta (AGE-SECRET-KEY-1…), no un destinatario: bórrala de aquí y no la compartas.',
|
||||
'not canonical': 'no es una clave X25519 canónica: nadie podría abrir su parte de la cápsula.',
|
||||
'low order': 'es una clave X25519 de orden bajo, que no protege nada.',
|
||||
duplicate: 'repite un destinatario de una línea anterior.',
|
||||
};
|
||||
|
||||
/**
|
||||
* The recipients of the text of the form, or the problem of its first bad
|
||||
* line, by number and never by content (recipient.ts).
|
||||
*/
|
||||
export function readRecipients(text: string): { ok: true; keys: Uint8Array[] } | { ok: false; problem: string } {
|
||||
try {
|
||||
return { ok: true, keys: parseRecipientList(text) };
|
||||
} catch (err) {
|
||||
const e = err as RecipientListError;
|
||||
return { ok: false, problem: `La línea ${e.line} ${LINE_PROBLEMS[e.problem]}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The capsule that the form asks for at `nowMs`, or the first problem, in
|
||||
* the order of the form. The requested instant must be after `nowMs` (§62.1,
|
||||
* rule 2), and the page checks it again with the clock when encrypting.
|
||||
*/
|
||||
export function planCapsule(input: CreateInput, nowMs: number): Planned {
|
||||
const fail = (field: CreateField, problem: string): Planned => ({ ok: false, field, problem });
|
||||
if (input.fileSize === undefined) return fail('file', 'Elige el fichero que guardará la cápsula.');
|
||||
if (input.fileSize > MAX_PAYLOAD_LENGTH) return fail('file', `El fichero ocupa más de ${formatByteCount(MAX_PAYLOAD_LENGTH)}, el máximo de una cápsula (§29.1).`);
|
||||
if (input.date === '') return fail('date', 'Elige el día de apertura.');
|
||||
if (input.time === '') return fail('time', 'Elige la hora de apertura.');
|
||||
const local = localToEpochMs(input.date, input.time, input.timeZone);
|
||||
if (!local.ok) {
|
||||
if (local.reason === 'zone') return fail('zone', 'Este navegador no conoce esa zona horaria.');
|
||||
if (local.reason === 'nonexistent') {
|
||||
return fail('time', `Esa hora no existe en ${input.timeZone}: ese día los relojes se adelantan y se la saltan. Elige otra.`);
|
||||
}
|
||||
return fail('date', 'La fecha o la hora no son válidas.');
|
||||
}
|
||||
const requestedMs = local.epochMs;
|
||||
if (requestedMs <= nowMs) return fail('date', 'Esa fecha ya ha pasado según el reloj de este dispositivo.');
|
||||
const seconds = Math.floor(requestedMs / 1000);
|
||||
const requested: Instant = { seconds, nanos: (requestedMs - seconds * 1000) * 1e6 };
|
||||
const p = quicknet();
|
||||
// After the clock of the device and before Quicknet began: that clock is
|
||||
// behind.
|
||||
if (seconds < p.genesisTime) {
|
||||
return fail(
|
||||
'date',
|
||||
'Esa fecha es anterior al comienzo de Quicknet, la red de drand que usa DateKeys, el 23 de agosto de 2023 a las 15:09:27 UTC: ninguna ronda la abre. Si para ti es una fecha futura, el reloj de este dispositivo va atrasado.',
|
||||
);
|
||||
}
|
||||
let dateKey: DateKey;
|
||||
try {
|
||||
dateKey = resolveDateKey(p, requested);
|
||||
} catch {
|
||||
return fail('date', 'La fecha más lejana posible es el 31 de diciembre de 9999 a las 23:59:57 UTC, la última ronda de Quicknet.');
|
||||
}
|
||||
|
||||
const policy = input.policy;
|
||||
let recipients: Uint8Array[] = [];
|
||||
let portable = false;
|
||||
if (policy === TIME_AND_KEY) {
|
||||
const read = readRecipients(input.recipients);
|
||||
if (!read.ok) return fail('recipients', read.problem);
|
||||
recipients = read.keys;
|
||||
portable = input.portable;
|
||||
if (recipients.length === 0 && !portable) {
|
||||
return fail('portable', 'Sin destinatarios, la clave portable es la única credencial de la cápsula: déjala marcada o añade un destinatario.');
|
||||
}
|
||||
if (recipients.length + (portable ? 1 : 0) > ACCESS_SLOTS) {
|
||||
return fail(
|
||||
'recipients',
|
||||
`Una cápsula admite como mucho ${ACCESS_SLOTS} credenciales: ${ACCESS_SLOTS - 1} destinatarios con la clave portable, o ${ACCESS_SLOTS} sin ella. Hay ${formatInteger(recipients.length)} destinatarios.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const effective = roundTime(p, dateKey.round);
|
||||
const effectiveMs = effective.seconds * 1000;
|
||||
const now: Instant = { seconds: Math.floor(nowMs / 1000), nanos: (nowMs % 1000) * 1e6 };
|
||||
const length = input.fileSize;
|
||||
return {
|
||||
ok: true,
|
||||
plan: {
|
||||
requested,
|
||||
requestedMs,
|
||||
ambiguous: local.ambiguous,
|
||||
dateKey,
|
||||
dk1: compactDateKey(dateKey),
|
||||
effective,
|
||||
effectiveMs,
|
||||
policy,
|
||||
recipients,
|
||||
portable,
|
||||
length,
|
||||
paddedLength: paddedLength(length, REFORZADO),
|
||||
size: capsuleLength({ profileId: dateKey.profileId, round: dateKey.round, policy, length }),
|
||||
longHorizon: isLongHorizon(effective, now),
|
||||
soon: effectiveMs - nowMs < SOON_MS,
|
||||
names: defaultFileNames(effectiveMs),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The names offered for the files of a capsule that opens at `effectiveMs`:
|
||||
* capsula-<date and time of opening, UTC>.dkc and .dkk. The date is already
|
||||
* public in the DateKey, and says nothing of when the capsule was made.
|
||||
*/
|
||||
export function defaultFileNames(effectiveMs: number): { dkc: string; dkk: string } {
|
||||
const stamp = new Date(effectiveMs).toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '');
|
||||
return { dkc: `capsula-${stamp}.dkc`, dkk: `capsula-${stamp}.dkk` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to save a file under, from what the person wrote: its characters
|
||||
* that are not printable replaced (safeFileName), no directory separators,
|
||||
* and the extension `ext` added when it lacks it. An empty name takes
|
||||
* `fallback`.
|
||||
*/
|
||||
export function downloadName(name: string, ext: string, fallback: string): string {
|
||||
const base = safeFileName(name.trim()).replace(/[/\\]/g, '_');
|
||||
if (base === '') return fallback;
|
||||
return base.toLowerCase().endsWith(ext) ? base : `${base}${ext}`;
|
||||
}
|
||||
@ -0,0 +1,195 @@
|
||||
// Tests of creator.ts: the writing of the create page, in memory and into a
|
||||
// temporary file, with the person's cancellation, the room of the browser and
|
||||
// the checks of what it wrote against what the page showed. What it writes
|
||||
// opens. encodeAccessKey is wrapped to keep the .dkk it encodes, so that its
|
||||
// wiping on a failure can be seen.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { decodeAccessKey } from '../dkc/accesskey.ts';
|
||||
import { concatBytes, toHex } from '../dkc/bytes.ts';
|
||||
import { TIME_AND_KEY } from '../dkc/header.ts';
|
||||
import { open } from '../dkc/open.ts';
|
||||
import { suppliedRelease } from '../dkc/release.ts';
|
||||
import { h, readJSON } from '../dkc/testing/testdata.ts';
|
||||
import { type CapsulePlan, type CreateInput, planCapsule } from './create-input.ts';
|
||||
import { createCapsule, CreateStopped } from './creator.ts';
|
||||
import type { TempFile } from './tempfile.ts';
|
||||
|
||||
const encoded = vi.hoisted(() => [] as Uint8Array[]);
|
||||
vi.mock('../dkc/index.ts', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('../dkc/index.ts')>();
|
||||
return {
|
||||
...mod,
|
||||
encodeAccessKey: (k: Parameters<typeof mod.encodeAccessKey>[0]): Uint8Array => {
|
||||
const b = mod.encodeAccessKey(k);
|
||||
encoded.push(b);
|
||||
return b;
|
||||
},
|
||||
};
|
||||
});
|
||||
const wiped = (b: Uint8Array | undefined): boolean => b !== undefined && b.length > 0 && b.every((x) => x === 0);
|
||||
|
||||
const GENESIS_MS = Date.UTC(2023, 7, 23, 15, 9, 27);
|
||||
const genesis = () => ({ seconds: GENESIS_MS / 1000, nanos: 0 });
|
||||
const RELEASE = (() => {
|
||||
const r = readJSON<{ release: { round: number; signature: string } }>('fixtures/time_only.json').release;
|
||||
return { round: r.round, signature: h(r.signature) };
|
||||
})();
|
||||
|
||||
// A plan for round 1000, whose release is published.
|
||||
function plan(extra: Partial<CreateInput> = {}, nowMs = GENESIS_MS): CapsulePlan {
|
||||
const r = planCapsule(
|
||||
{ fileSize: 0, date: '2023-08-23', time: '15:59:24', timeZone: 'UTC', policy: 0, recipients: '', portable: true, ...extra },
|
||||
nowMs,
|
||||
);
|
||||
if (!r.ok) throw new Error(r.problem);
|
||||
return r.plan;
|
||||
}
|
||||
|
||||
function content(n: number): Uint8Array {
|
||||
return Uint8Array.from({ length: n }, (_, i) => (i * 7 + 3) & 0xff);
|
||||
}
|
||||
|
||||
// A temporary file of the page, in memory; `read` makes what file() gives
|
||||
// from what was written.
|
||||
function temp(read = async (b: Uint8Array): Promise<File> => new File([b as Uint8Array<ArrayBuffer>], 'capsule')): TempFile & {
|
||||
chunks: Uint8Array[];
|
||||
state: { closed: boolean; aborted: unknown };
|
||||
} {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const state: { closed: boolean; aborted: unknown } = { closed: false, aborted: undefined };
|
||||
return {
|
||||
writable: new WritableStream<Uint8Array>({
|
||||
write: (c) => void chunks.push(c.slice()),
|
||||
close: () => void (state.closed = true),
|
||||
abort: (reason) => void (state.aborted = reason ?? 'aborted'),
|
||||
}),
|
||||
file: () => read(concatBytes(...chunks)),
|
||||
remove: async () => undefined,
|
||||
chunks,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
async function failure(p: Promise<unknown>): Promise<Error> {
|
||||
try {
|
||||
await p;
|
||||
} catch (err) {
|
||||
return err as Error;
|
||||
}
|
||||
throw new Error('expected a failure');
|
||||
}
|
||||
|
||||
async function opened(capsule: Blob, extra: { accessKeyFile?: Uint8Array } = {}): Promise<Uint8Array | undefined> {
|
||||
const r = await open(capsule, { source: suppliedRelease(RELEASE), now: () => ({ seconds: RELEASE.round * 3 + GENESIS_MS / 1000, nanos: 0 }), ...extra });
|
||||
expect(r.error).toBeUndefined();
|
||||
return r.plaintext;
|
||||
}
|
||||
|
||||
describe('createCapsule', () => {
|
||||
it('writes a time_only capsule in memory, of the size planned, that inspect accepts and open opens', async () => {
|
||||
const body = content(5000);
|
||||
const p = plan({ fileSize: body.length });
|
||||
const progress: [number, number][] = [];
|
||||
const c = await createCapsule({ file: new Blob([body as Uint8Array<ArrayBuffer>]), plan: p, cancelled: () => false, now: genesis, progress: (w, t) => void progress.push([w, t]) });
|
||||
expect([c.capsule.size, c.dkk, c.inspection.error]).toEqual([p.size, undefined, undefined]);
|
||||
expect(c.capsuleId).toBe(toHex(c.inspection.header!.capsuleId));
|
||||
expect(c.inspection.header!.dateKey.round).toBe(1000);
|
||||
expect(c.bytes.length).toBeLessThanOrEqual(p.size);
|
||||
expect([progress[0], progress.at(-1)]).toEqual([
|
||||
[0, p.size],
|
||||
[p.size, p.size],
|
||||
]);
|
||||
expect(c.ms).toBeGreaterThan(0);
|
||||
expect(await opened(c.capsule)).toEqual(body);
|
||||
});
|
||||
|
||||
it('writes a time_and_key capsule into the temporary file, closed, with a .dkk that opens it', async () => {
|
||||
const body = content(70_000);
|
||||
const p = plan({ fileSize: body.length, policy: TIME_AND_KEY, portable: true });
|
||||
const out = temp();
|
||||
const c = await createCapsule({ file: new Blob([body as Uint8Array<ArrayBuffer>]), plan: p, output: out, room: p.size, cancelled: () => false, now: genesis });
|
||||
expect([out.state.closed, out.state.aborted, c.capsule.size]).toEqual([true, undefined, p.size]);
|
||||
expect(c.capsule).toBeInstanceOf(File);
|
||||
expect(decodeAccessKey(c.dkk!).capsuleId).toEqual(c.inspection.header!.capsuleId);
|
||||
expect([c.dkk, wiped(c.dkk)]).toEqual([encoded.at(-1), false]);
|
||||
expect(await opened(c.capsule, { accessKeyFile: c.dkk!.slice() })).toEqual(body);
|
||||
});
|
||||
|
||||
it('writes with the clock of the system when given none, and without a progress callback', async () => {
|
||||
const at = new Date(Date.now() + 2 * 3600_000).toISOString();
|
||||
const p = plan({ fileSize: 3, date: at.slice(0, 10), time: at.slice(11, 19) }, Date.now());
|
||||
const c = await createCapsule({ file: new Blob([new Uint8Array(3)]), plan: p, cancelled: () => false });
|
||||
expect([c.capsule.size, c.inspection.header!.dateKey.round]).toEqual([p.size, p.dateKey.round]);
|
||||
});
|
||||
|
||||
it('stops when the person cancels: before writing, or after the piece in progress, the output aborted', async () => {
|
||||
const p = plan({ fileSize: 300_000 });
|
||||
const out = temp();
|
||||
const err = await failure(createCapsule({ file: new Blob([content(300_000) as Uint8Array<ArrayBuffer>]), plan: p, output: out, cancelled: () => true, now: genesis }));
|
||||
expect(err).toBeInstanceOf(CreateStopped);
|
||||
expect([(err as CreateStopped).reason, (err as CreateStopped).total, err.message]).toEqual(['cancelled', p.size, 'create: cancelled']);
|
||||
expect([out.chunks.length, out.state.closed, out.state.aborted]).toEqual([0, false, err]);
|
||||
|
||||
let calls = 0;
|
||||
const out2 = temp();
|
||||
const err2 = await failure(
|
||||
createCapsule({ file: new Blob([content(300_000) as Uint8Array<ArrayBuffer>]), plan: p, output: out2, cancelled: () => ++calls > 5, now: genesis }),
|
||||
);
|
||||
expect((err2 as CreateStopped).reason).toBe('cancelled');
|
||||
expect(out2.chunks.length).toBeGreaterThan(0);
|
||||
expect([out2.state.closed, out2.state.aborted]).toEqual([false, err2]);
|
||||
});
|
||||
|
||||
it('writes nothing when the .dkc does not fit in the room of the browser', async () => {
|
||||
const p = plan({ fileSize: 10 });
|
||||
const out = temp();
|
||||
const err = await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: p, output: out, room: p.size - 1, cancelled: () => false, now: genesis }));
|
||||
expect([(err as CreateStopped).reason, (err as CreateStopped).total, err.message]).toEqual(['room', p.size, `create: the .dkc of ${p.size} bytes does not fit`]);
|
||||
expect([out.chunks.length, out.state.aborted]).toEqual([0, err]);
|
||||
});
|
||||
|
||||
it('refuses a capsule of another size or round than the page showed, and wipes its .dkk', async () => {
|
||||
const p = plan({ fileSize: 10 });
|
||||
const bigger = { ...p, size: p.size + 1 };
|
||||
expect((await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: bigger, cancelled: () => false, now: genesis }))).message).toBe(
|
||||
`create: internal error: wrote ${p.size} bytes for round 1000, planned ${p.size + 1} for round 1000`,
|
||||
);
|
||||
const keyed = plan({ fileSize: 10, policy: TIME_AND_KEY });
|
||||
const other = { ...keyed, dateKey: { ...keyed.dateKey, round: 999 } };
|
||||
expect((await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: other, cancelled: () => false, now: genesis }))).message).toBe(
|
||||
`create: internal error: wrote ${keyed.size} bytes for round 1000, planned ${keyed.size} for round 999`,
|
||||
);
|
||||
expect(wiped(encoded.at(-1))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not offer a .dkc whose file is not what was written or that inspect rejects, and wipes its .dkk', async () => {
|
||||
const keyed = plan({ fileSize: 10, policy: TIME_AND_KEY });
|
||||
const make = (out: TempFile) => createCapsule({ file: new Blob([new Uint8Array(10)]), plan: keyed, output: out, cancelled: () => false, now: genesis });
|
||||
|
||||
const shorter = temp(async (b) => new File([b.subarray(1) as Uint8Array<ArrayBuffer>], 'capsule'));
|
||||
expect((await failure(make(shorter))).message).toBe(`create: internal error: the .dkc written is ${keyed.size - 1} bytes, planned ${keyed.size}`);
|
||||
expect(wiped(encoded.at(-1))).toBe(true);
|
||||
|
||||
// The first byte of PUBLIC_HEADER changed: step 4 does not decode it.
|
||||
const changed = temp(async (b) => {
|
||||
const c = b.slice();
|
||||
c[16]! ^= 0xff;
|
||||
return new File([c as Uint8Array<ArrayBuffer>], 'capsule');
|
||||
});
|
||||
expect((await failure(make(changed))).message).toMatch(/^create: internal error: the \.dkc written fails step 4: capsule: PUBLIC_HEADER: /);
|
||||
expect(wiped(encoded.at(-1))).toBe(true);
|
||||
|
||||
const gone = new Error('NotFoundError: the file is gone');
|
||||
expect(await failure(make(temp(() => Promise.reject(gone))))).toBe(gone);
|
||||
expect(wiped(encoded.at(-1))).toBe(true);
|
||||
});
|
||||
|
||||
it('rethrows the errors of encrypt as they are', async () => {
|
||||
const p = plan({ fileSize: 10 });
|
||||
const late = () => ({ seconds: p.requested.seconds, nanos: 0 });
|
||||
expect((await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: p, cancelled: () => false, now: late }))).message).toBe(
|
||||
'capsule: unlock time 2023-08-23T15:59:24Z is not in the future',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,105 @@
|
||||
// The writing of a capsule, which the create page loads on demand with a
|
||||
// dynamic import: it carries encrypt.ts and with it noble and
|
||||
// age-encryption, which the first load of the page does not need (plan of
|
||||
// phase 3, section 9). Nothing goes to the network: the round is resolved in
|
||||
// the browser, and tlock uses only the pinned public key (§35).
|
||||
|
||||
import { encodeAccessKey, type Inspection, type Instant, inspect, quicknet, readCapsule, toHex, wipeAccessKey } from '../dkc/index.ts';
|
||||
import { encrypt } from '../dkc/encrypt.ts';
|
||||
import type { CapsulePlan } from './create-input.ts';
|
||||
import { systemClock } from './opener.ts';
|
||||
import type { TempFile } from './tempfile.ts';
|
||||
|
||||
export interface CreateRequest {
|
||||
/** The person's file: its L bytes are the content. */
|
||||
readonly file: Blob;
|
||||
/** What the form asked for, checked by planCapsule. */
|
||||
readonly plan: CapsulePlan;
|
||||
/** Where the .dkc goes; memory when omitted. */
|
||||
readonly output?: TempFile;
|
||||
/** The bytes the .dkc may take, the free space that the browser reports; no limit when omitted. */
|
||||
readonly room?: number;
|
||||
/** Whether the person cancelled: the writing stops after the piece in progress. */
|
||||
readonly cancelled: () => boolean;
|
||||
/** Called with (0, total) before the first write, then after each piece. */
|
||||
readonly progress?: (written: number, total: number) => void;
|
||||
/** The clock; the system clock when omitted. It must still be before the requested instant. */
|
||||
readonly now?: () => Instant;
|
||||
}
|
||||
|
||||
/** A capsule written and checked. */
|
||||
export interface Created {
|
||||
/** The .dkc: the temporary file, or a Blob in memory. */
|
||||
readonly capsule: Blob;
|
||||
/** The encoded .dkk, when the plan asked for a portable key: the caller wipes it. */
|
||||
readonly dkk?: Uint8Array;
|
||||
readonly capsuleId: string;
|
||||
/** Steps 1 to 8 of the .dkc written, and the bytes of the .dkc that they read. */
|
||||
readonly inspection: Inspection;
|
||||
readonly bytes: Uint8Array;
|
||||
/** How long encrypt took, in milliseconds. */
|
||||
readonly ms: number;
|
||||
}
|
||||
|
||||
/** The writing stopped because the person cancelled it, or before writing anything because the .dkc did not fit in `room`. */
|
||||
export class CreateStopped extends Error {
|
||||
readonly reason: 'cancelled' | 'room';
|
||||
/** The size of the .dkc. */
|
||||
readonly total: number;
|
||||
|
||||
constructor(reason: 'cancelled' | 'room', total: number) {
|
||||
super(reason === 'cancelled' ? 'create: cancelled' : `create: the .dkc of ${total} bytes does not fit`);
|
||||
this.name = 'CreateStopped';
|
||||
this.reason = reason;
|
||||
this.total = total;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the capsule of `req.plan` with the content of `req.file`, and
|
||||
* inspects what it wrote. The output is closed only once the capsule is
|
||||
* complete and checked, and aborted on any failure, a cancellation
|
||||
* included; the errors of encrypt are rethrown as they are.
|
||||
*/
|
||||
export async function createCapsule(req: CreateRequest): Promise<Created> {
|
||||
const { plan } = req;
|
||||
const start = performance.now();
|
||||
const res = await encrypt(req.file, {
|
||||
profile: quicknet(),
|
||||
unlockAt: plan.requested,
|
||||
policy: plan.policy,
|
||||
recipients: plan.recipients,
|
||||
newPortableKey: plan.portable,
|
||||
now: req.now ?? systemClock,
|
||||
...(req.output === undefined ? {} : { output: req.output.writable }),
|
||||
progress: (written, total) => {
|
||||
if (req.cancelled()) throw new CreateStopped('cancelled', total);
|
||||
if (written === 0 && req.room !== undefined && total > req.room) throw new CreateStopped('room', total);
|
||||
req.progress?.(written, total);
|
||||
},
|
||||
});
|
||||
const ms = performance.now() - start;
|
||||
let dkk: Uint8Array | undefined;
|
||||
if (res.portableKey !== undefined) {
|
||||
dkk = encodeAccessKey(res.portableKey);
|
||||
wipeAccessKey(res.portableKey);
|
||||
}
|
||||
// A capsule that is not what the page showed, or that steps 1 to 8 reject,
|
||||
// is not offered (§62.1, rule 9); its .dkk is wiped on any failure.
|
||||
try {
|
||||
if (res.size !== plan.size || res.dateKey.round !== plan.dateKey.round) {
|
||||
throw new Error(`create: internal error: wrote ${res.size} bytes for round ${res.dateKey.round}, planned ${plan.size} for round ${plan.dateKey.round}`);
|
||||
}
|
||||
const capsule = req.output === undefined ? new Blob([res.dkc! as Uint8Array<ArrayBuffer>]) : await req.output.file();
|
||||
if (capsule.size !== plan.size) throw new Error(`create: internal error: the .dkc written is ${capsule.size} bytes, planned ${plan.size}`);
|
||||
const { bytes } = await readCapsule(capsule);
|
||||
const inspection = await inspect(bytes);
|
||||
if (inspection.error !== undefined) {
|
||||
throw new Error(`create: internal error: the .dkc written fails step ${inspection.checks.at(-1)!.step}: ${inspection.error.message}`);
|
||||
}
|
||||
return { capsule, ...(dkk === undefined ? {} : { dkk }), capsuleId: toHex(res.capsuleId), inspection, bytes, ms };
|
||||
} catch (err) {
|
||||
dkk?.fill(0);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
// Tests of localtime.ts: local dates and times in a zone as the UTC instant
|
||||
// they stand for, with the zones that skip or repeat an hour or half an hour.
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { isTimeZone, localParts, localToEpochMs, parseLocal, supportedTimeZones, timeZoneList, UTC } from './localtime.ts';
|
||||
|
||||
const iso = (date: string, time: string, zone: string): string => {
|
||||
const r = localToEpochMs(date, time, zone);
|
||||
return r.ok ? `${new Date(r.epochMs).toISOString()}${r.ambiguous ? ' ambiguous' : ''}` : r.reason;
|
||||
};
|
||||
|
||||
describe('localToEpochMs', () => {
|
||||
it('gives the instant of a date and time in a zone, with its offset of that day', () => {
|
||||
expect(iso('2030-01-01', '00:00', UTC)).toBe('2030-01-01T00:00:00.000Z');
|
||||
expect(iso('2030-01-15', '12:00', 'Europe/Madrid')).toBe('2030-01-15T11:00:00.000Z');
|
||||
expect(iso('2030-07-15', '12:00', 'Europe/Madrid')).toBe('2030-07-15T10:00:00.000Z');
|
||||
expect(iso('2030-01-01', '00:00', 'Asia/Kolkata')).toBe('2029-12-31T18:30:00.000Z');
|
||||
// The widest offsets there are, +14 and -12.
|
||||
expect(iso('2030-01-01', '00:00', 'Pacific/Kiritimati')).toBe('2029-12-31T10:00:00.000Z');
|
||||
expect(iso('2030-01-01', '00:00', 'Etc/GMT+12')).toBe('2030-01-01T12:00:00.000Z');
|
||||
// Up to the last round of Quicknet, with a zone ahead of UTC.
|
||||
expect(iso('9999-12-31', '23:59:57', UTC)).toBe('9999-12-31T23:59:57.000Z');
|
||||
expect(iso('9999-12-31', '23:59:57', 'Pacific/Kiritimati')).toBe('9999-12-31T09:59:57.000Z');
|
||||
});
|
||||
|
||||
it('rejects a time that the zone skips when the clocks go forward', () => {
|
||||
expect(iso('2030-03-31', '01:59', 'Europe/Madrid')).toBe('2030-03-31T00:59:00.000Z');
|
||||
expect(iso('2030-03-31', '02:00', 'Europe/Madrid')).toBe('nonexistent');
|
||||
expect(iso('2030-03-31', '02:30', 'Europe/Madrid')).toBe('nonexistent');
|
||||
expect(iso('2030-03-31', '03:00', 'Europe/Madrid')).toBe('2030-03-31T01:00:00.000Z');
|
||||
expect(iso('2030-03-10', '02:30', 'America/New_York')).toBe('nonexistent');
|
||||
// Lord Howe moves its clocks by half an hour.
|
||||
expect(iso('2030-10-06', '02:15', 'Australia/Lord_Howe')).toBe('nonexistent');
|
||||
});
|
||||
|
||||
it('takes the later instant of a time that the zone repeats when the clocks go back', () => {
|
||||
expect(iso('2030-10-27', '01:59', 'Europe/Madrid')).toBe('2030-10-26T23:59:00.000Z');
|
||||
expect(iso('2030-10-27', '02:00', 'Europe/Madrid')).toBe('2030-10-27T01:00:00.000Z ambiguous');
|
||||
expect(iso('2030-10-27', '02:30', 'Europe/Madrid')).toBe('2030-10-27T01:30:00.000Z ambiguous');
|
||||
expect(iso('2030-10-27', '03:00', 'Europe/Madrid')).toBe('2030-10-27T02:00:00.000Z');
|
||||
expect(iso('2030-11-03', '01:30', 'America/New_York')).toBe('2030-11-03T06:30:00.000Z ambiguous');
|
||||
expect(iso('2030-04-07', '01:45', 'Australia/Lord_Howe')).toBe('2030-04-06T15:15:00.000Z ambiguous');
|
||||
});
|
||||
|
||||
it('keeps the seconds and milliseconds of the time', () => {
|
||||
expect(iso('2030-01-01', '12:00:30', UTC)).toBe('2030-01-01T12:00:30.000Z');
|
||||
expect(iso('2030-01-01', '12:00:30.5', 'Europe/Madrid')).toBe('2030-01-01T11:00:30.500Z');
|
||||
expect(iso('2030-01-01', '12:00:30.123', UTC)).toBe('2030-01-01T12:00:30.123Z');
|
||||
});
|
||||
|
||||
it('refuses what is not a date and a time, and a zone that the browser does not know', () => {
|
||||
for (const [date, time] of [
|
||||
['2030-02-30', '00:00'],
|
||||
['2031-02-29', '00:00'],
|
||||
['2030-04-31', '00:00'],
|
||||
['2030-13-01', '00:00'],
|
||||
['2030-00-10', '00:00'],
|
||||
['2030-01-00', '00:00'],
|
||||
['0000-01-01', '00:00'],
|
||||
['2030-1-1', '00:00'],
|
||||
['', '00:00'],
|
||||
['2030-01-01', ''],
|
||||
['2030-01-01', '24:00'],
|
||||
['2030-01-01', '12:60'],
|
||||
['2030-01-01', '12:00:60'],
|
||||
['2030-01-01', '12:00:00.1234'],
|
||||
['2030-01-01', '1:00'],
|
||||
]) {
|
||||
expect(iso(date!, time!, UTC), `${date} ${time}`).toBe('format');
|
||||
}
|
||||
expect(iso('2032-02-29', '00:00', UTC)).toBe('2032-02-29T00:00:00.000Z');
|
||||
expect(iso('2030-01-01', '00:00', 'Mars/Olympus')).toBe('zone');
|
||||
expect(isTimeZone('Europe/Madrid')).toBe(true);
|
||||
expect(isTimeZone(UTC)).toBe(true);
|
||||
expect(isTimeZone('Mars/Olympus')).toBe(false);
|
||||
// What V8 reports as the zone of a device whose zone ICU does not know.
|
||||
expect(isTimeZone('Etc/Unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('agrees, in every zone, with a search of every quarter hour around each change of offset of 2030', () => {
|
||||
// An independent wall clock of a zone: the date and time it shows at an
|
||||
// instant, as milliseconds of a UTC date and time.
|
||||
const formats = new Map<string, Intl.DateTimeFormat>();
|
||||
const wall = (zone: string, ms: number): number => {
|
||||
let f = formats.get(zone);
|
||||
if (f === undefined) {
|
||||
f = new Intl.DateTimeFormat('en-US-u-ca-gregory-nu-latn', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' });
|
||||
formats.set(zone, f);
|
||||
}
|
||||
const p = Object.fromEntries(f.formatToParts(ms).map(({ type, value }) => [type, Number(value)]));
|
||||
return Date.UTC(p.year!, p.month! - 1, p.day!, p.hour!, p.minute!, p.second!);
|
||||
};
|
||||
const MIN = 60_000;
|
||||
const Q = 15 * MIN;
|
||||
let checked = 0;
|
||||
for (const zone of supportedTimeZones()) {
|
||||
const offset = (ms: number) => wall(zone, ms) - ms;
|
||||
for (let at = Date.UTC(2030, 0, 1); at < Date.UTC(2031, 0, 1); at += 7 * 86_400_000) {
|
||||
if (offset(at) === offset(at + 7 * 86_400_000)) continue;
|
||||
// The change, to the minute.
|
||||
let [lo, hi] = [at, at + 7 * 86_400_000];
|
||||
while (hi - lo > MIN) {
|
||||
const mid = lo + Math.floor((hi - lo) / 2 / MIN) * MIN;
|
||||
if (offset(mid) === offset(lo)) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
const [before, after] = [offset(lo), offset(hi)];
|
||||
for (const w of [hi + before - Q, hi + before, hi + before + Q, hi + after - Q, hi + after, hi + after + Q]) {
|
||||
// Every instant within 14 hours whose wall clock is w.
|
||||
const found: number[] = [];
|
||||
for (let t = w - 14 * 3_600_000; t <= w + 14 * 3_600_000; t += Q) if (wall(zone, t) === w) found.push(t);
|
||||
const iso = new Date(w).toISOString();
|
||||
const got = localToEpochMs(iso.slice(0, 10), iso.slice(11, 16), zone);
|
||||
const want = found.length === 0 ? { ok: false, reason: 'nonexistent' } : { ok: true, epochMs: Math.max(...found), ambiguous: found.length > 1 };
|
||||
expect(got, `${zone} ${iso}`).toEqual(want);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// With the zones of Node 24, 130 change their clocks twice in 2030: 1 560
|
||||
// cases. The bound only guards against a sweep that finds nothing.
|
||||
expect(checked).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it('reads years of one to three digits as they are, not as 19xx', () => {
|
||||
expect(new Date(parseLocal('0099-06-01', '00:00')!).getUTCFullYear()).toBe(99);
|
||||
expect(new Date(parseLocal('0001-01-01', '00:00')!).toISOString()).toBe('0001-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('localParts', () => {
|
||||
it('writes an instant as the inputs of a zone show it', () => {
|
||||
expect(localParts(Date.UTC(2030, 6, 15, 10), 'Europe/Madrid')).toEqual({ date: '2030-07-15', time: '12:00' });
|
||||
expect(localParts(Date.UTC(2029, 11, 31, 18, 30, 59, 999), 'Asia/Kolkata')).toEqual({ date: '2030-01-01', time: '00:00' });
|
||||
expect(localParts(Date.UTC(2030, 0, 1, 9, 5), UTC)).toEqual({ date: '2030-01-01', time: '09:05' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the list of zones', () => {
|
||||
const original = Intl.supportedValuesOf;
|
||||
afterEach(() => {
|
||||
Object.defineProperty(Intl, 'supportedValuesOf', { value: original, configurable: true, writable: true });
|
||||
});
|
||||
|
||||
it('puts the device first, then UTC, then the rest in order and once', () => {
|
||||
const supported = ['Europe/Madrid', 'America/New_York', 'Africa/Abidjan', 'Africa/Abidjan', UTC];
|
||||
expect(timeZoneList('Europe/Madrid', supported)).toEqual(['Europe/Madrid', UTC, 'Africa/Abidjan', 'America/New_York']);
|
||||
expect(timeZoneList(undefined, supported)).toEqual([UTC, 'Africa/Abidjan', 'America/New_York', 'Europe/Madrid']);
|
||||
expect(timeZoneList(UTC, supported)).toEqual([UTC, 'Africa/Abidjan', 'America/New_York', 'Europe/Madrid']);
|
||||
// An alias that the list of the browser leaves out.
|
||||
expect(timeZoneList('Asia/Calcutta', ['Asia/Kolkata'])).toEqual(['Asia/Calcutta', UTC, 'Asia/Kolkata']);
|
||||
});
|
||||
|
||||
it('asks the browser for its zones, and has none without Intl.supportedValuesOf', () => {
|
||||
expect(supportedTimeZones()).toContain('Europe/Madrid');
|
||||
Object.defineProperty(Intl, 'supportedValuesOf', { value: undefined, configurable: true, writable: true });
|
||||
expect(supportedTimeZones()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,143 @@
|
||||
// Local dates and times of the create page (plan of phase 3, section 9): the
|
||||
// person picks a date and a time in a time zone, and the capsule seals the
|
||||
// UTC instant they stand for (§15), with the rules of the zone as this
|
||||
// browser knows them today; the zone itself is not kept. Intl only:
|
||||
// formatToParts gives the wall clock of a zone at an instant, and the
|
||||
// instants of a wall clock are found from the offsets of the zone around it.
|
||||
// A time that the zone skips, when the clock goes forward, does not exist. A
|
||||
// time that it repeats, when the clock goes back, is ambiguous, and the later
|
||||
// of its two instants is taken, so that a capsule never opens before the
|
||||
// person could have meant.
|
||||
|
||||
/** The zone that is no zone: the instant itself. */
|
||||
export const UTC = 'UTC';
|
||||
|
||||
const HOUR = 3600_000;
|
||||
|
||||
// The Gregorian calendar and Latin digits, whatever the locale of the device.
|
||||
const LOCALE = 'en-US-u-ca-gregory-nu-latn';
|
||||
|
||||
const formatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
// The formatter of the wall clock of `timeZone`; a RangeError for a zone that
|
||||
// this browser does not know.
|
||||
function formatter(timeZone: string): Intl.DateTimeFormat {
|
||||
let f = formatters.get(timeZone);
|
||||
if (f === undefined) {
|
||||
f = new Intl.DateTimeFormat(LOCALE, {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
formatters.set(timeZone, f);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
// Milliseconds since the epoch of a date and time taken as UTC, for any year
|
||||
// from 1: Date.UTC reads the years 0 to 99 as 1900 to 1999.
|
||||
function utcMs(year: number, month: number, day: number, hour: number, minute: number, second: number): number {
|
||||
const d = new Date(Date.UTC(2000, month - 1, day, hour, minute, second));
|
||||
d.setUTCFullYear(year);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
// The wall clock of the zone of `f` at `epochMs`, as milliseconds of a UTC
|
||||
// date and time.
|
||||
function wallMs(epochMs: number, f: Intl.DateTimeFormat): number {
|
||||
const p: Record<string, number> = {};
|
||||
for (const { type, value } of f.formatToParts(epochMs)) if (type !== 'literal') p[type] = Number(value);
|
||||
const ms = ((epochMs % 1000) + 1000) % 1000;
|
||||
return utcMs(p.year!, p.month!, p.day!, p.hour!, p.minute!, p.second!) + ms;
|
||||
}
|
||||
|
||||
/** Whether this browser knows the time zone `timeZone`. */
|
||||
export function isTimeZone(timeZone: string): boolean {
|
||||
try {
|
||||
formatter(timeZone);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The date and time of the values of <input type="date"> and <input
|
||||
* type="time">, "YYYY-MM-DD" and "HH:MM", "HH:MM:SS" or "HH:MM:SS.mmm", as
|
||||
* milliseconds of a UTC date and time; undefined when either is not one.
|
||||
*/
|
||||
export function parseLocal(date: string, time: string): number | undefined {
|
||||
const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
||||
const t = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/.exec(time);
|
||||
if (d === null || t === null) return undefined;
|
||||
const [year, month, day] = [Number(d[1]), Number(d[2]), Number(d[3])];
|
||||
const [hour, minute, second] = [Number(t[1]), Number(t[2]), Number(t[3] ?? 0)];
|
||||
if (year < 1 || month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59 || second > 59) return undefined;
|
||||
const ms = utcMs(year, month, day, hour, minute, second);
|
||||
// A day past the end of its month rolls over into the next one.
|
||||
if (new Date(ms).getUTCDate() !== day) return undefined;
|
||||
return ms + Number((t[4] ?? '').padEnd(3, '0'));
|
||||
}
|
||||
|
||||
/** The instant of a local date and time in a zone, or why there is none. */
|
||||
export type LocalTime =
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly epochMs: number;
|
||||
/** The zone repeats this time, and the later of its two instants was taken. */
|
||||
readonly ambiguous: boolean;
|
||||
}
|
||||
| {
|
||||
readonly ok: false;
|
||||
/** Not a date and a time, a zone that this browser does not know, or a time that the zone skips. */
|
||||
readonly reason: 'format' | 'zone' | 'nonexistent';
|
||||
};
|
||||
|
||||
/**
|
||||
* The instant at which the clocks of `timeZone` show `date` and `time`, the
|
||||
* values of the inputs of the page. The offsets that the zone can have at
|
||||
* that time are those of the instants 14 hours around it, the widest offsets
|
||||
* there are; each gives an instant, kept if the zone shows that very time at
|
||||
* it.
|
||||
*/
|
||||
export function localToEpochMs(date: string, time: string, timeZone: string): LocalTime {
|
||||
const wall = parseLocal(date, time);
|
||||
if (wall === undefined) return { ok: false, reason: 'format' };
|
||||
if (!isTimeZone(timeZone)) return { ok: false, reason: 'zone' };
|
||||
const f = formatter(timeZone);
|
||||
const offsets = new Set([wall - 14 * HOUR, wall, wall + 14 * HOUR].map((at) => wallMs(at, f) - at));
|
||||
const instants = [...offsets].map((o) => wall - o).filter((at) => wallMs(at, f) === wall);
|
||||
if (instants.length === 0) return { ok: false, reason: 'nonexistent' };
|
||||
return { ok: true, epochMs: Math.max(...instants), ambiguous: instants.length > 1 };
|
||||
}
|
||||
|
||||
/** The date and the time of `epochMs` in `timeZone`, as the inputs of the page write them: "YYYY-MM-DD" and "HH:MM". */
|
||||
export function localParts(epochMs: number, timeZone: string): { date: string; time: string } {
|
||||
const at = new Date(wallMs(epochMs, formatter(timeZone)));
|
||||
const two = (n: number): string => String(n).padStart(2, '0');
|
||||
return {
|
||||
date: `${String(at.getUTCFullYear()).padStart(4, '0')}-${two(at.getUTCMonth() + 1)}-${two(at.getUTCDate())}`,
|
||||
time: `${two(at.getUTCHours())}:${two(at.getUTCMinutes())}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The zones to offer, the device's first: then UTC, then every other zone
|
||||
* that this browser knows, in alphabetical order. `supported` is
|
||||
* Intl.supportedValuesOf('timeZone'), which may leave out UTC and the name
|
||||
* that the device gives its own zone.
|
||||
*/
|
||||
export function timeZoneList(device: string | undefined, supported: readonly string[]): string[] {
|
||||
const first = device === undefined || device === UTC ? [UTC] : [device, UTC];
|
||||
return [...first, ...[...new Set(supported)].filter((z) => !first.includes(z)).sort()];
|
||||
}
|
||||
|
||||
/** Intl.supportedValuesOf('timeZone'), or none when this browser lacks it. */
|
||||
export function supportedTimeZones(): string[] {
|
||||
return typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : [];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue