Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cookbook: Add "Locked-down Temporal" example #1367

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/cookbook-mock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## Mock Temporal example

This is an example of how to create a "locked-down" version of Temporal that supports exactly the same interface, and is indistinguishable from the original, except that the date, time, time zone, and time zone data are under the control of the creator.

This is useful for secure environments like [SES](https://github.com/Agoric/ses-shim) where no information about the host system should be leaked to the program being run; purely functional environments like [Elm](https://elm-lang.org/) where functions must be pure even if the browser's locale data is updated; and mocking for testing purposes, where runs must be deterministic.

This is an example of an approach to take, illustrating shadowing the locale data, introducing a controllable clock time, and freezing Temporal.
Not everything in this example is needed for every application.
For example, in a test harness, you would probably only need to replace `Temporal.now` with a version using a controllable clock and constant time zone, and not need to freeze the Temporal object, or replace `Function.prototype.toString`.

At the same time, this example does not claim to be secure or complete enough for real security applications.
Other information can leak through channels not considered here, such as differences in performance of the underlying Temporal operations.

> **NOTE**: This is a very specialized use of Temporal and is not something you would normally need to do.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should also state that it is also not 100% guaranteed to not leak information about the host system; differences in performance of underlying temporal operations can still leak as a side channel (e.g. inform on the host VM implementation). It's a (good) example that doesn't claim to be perfectly secure or complete!

Per discussion below, should clarify that this example illustrates shadowing locale data, controllable clock time & precision, and freezing Temporal; it does not consider additional vectors.


```javascript
{{cookbook/makeMockTemporal.mjs}}
```
6 changes: 6 additions & 0 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,3 +471,9 @@ Extend Temporal to support arbitrarily-large years (e.g., **+635427810-02-02**)
An example of using `Temporal.TimeZone` for other purposes than a standard time zne.

→ [NYSE time zone](cookbook-nyse.md)

### Locked-down Temporal

"Lock down" the Temporal object so that it doesn't leak any information about the host system, and the system clock is controllable, for use in security applications or for mocking in tests.

→ [Locked-down Temporal](cookbook-mock.md)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as #1370 (comment), non-blocking, just curious as it's a departure

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered there, tl;dr: this one is longer and less general-purpose.

270 changes: 270 additions & 0 deletions docs/cookbook/makeMockTemporal.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
// First of all, create a controllable clock object that underlies the
// functions in the Temporal.now namespace, that we can tick forward or backward
// at will.
// We'll use the clock to remove Temporal's access to the system clock below.

class Clock {
epochNs = 0n;
tick(ticks = 1) {
this.epochNs += BigInt(ticks);
}
}
const clock = new Clock();

// Save the original Temporal functions that we will override but still need
// access to internally.

const realTemporalCalendar = Temporal.Calendar;
const realCalendarFrom = Temporal.Calendar.from;
const realTemporalTimeZone = Temporal.TimeZone;
const realTimeZoneFrom = Temporal.TimeZone.from;
const realTemporalNow = Temporal.now;

// Override the Temporal.Calendar constructor and Temporal.Calendar.from to
// disallow all calendars except the iso8601 calendar, otherwise insecure code
// might be able to tell something about the version of the host system's
// locale data.
Comment on lines +23 to +26
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this needed? if Temporal.now is locked down, then how can insecure code access the host system's data?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version of the locale data present on the host system is a browser fingerprinting vector. If you can construct dates using non-ISO calendars and convert them to ISO, then you may be able to tell (due to bugs being fixed, or adjustments to calendars based on astronomical sightings) which version of locale data is present.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i see, so this example is primarily about avoiding fingerprinting? that's usually not something a web site author wants to prevent, since they generally want to use that data for analytics.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example is a grab bag of modifications that are somewhat related. The blurb I wrote in cookbook-mock.md says that you wouldn't need all of them for every purpose. For example, for a test harness, you wouldn't bother with blocking access to the time zones and calendars, and for an environment like SES, you wouldn't bother with the controllable clock.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha.

this is way better than nothing, to be sure :-) but it might be less confusing as separate examples, targeted to a single use case.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough. I'll see what would be the best way to split it up after getting comments from others.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think comments like this serve the purpose of pointing out the use case of / reasons for each part - no need to further split up imo as there aren't that many things it locks down (clock precision & time, locale). Perhaps adding a bit more to the markdown intro about preventing clock & locale fingerprinting would be sufficient.


class Calendar extends realTemporalCalendar {
constructor(identifier) {
if (identifier !== 'iso8601') {
// match error message
throw new RangeError(`Invalid calendar: ${identifier}`);
}
super(identifier);
}

static from(item) {
const calendar = realCalendarFrom.call(realTemporalCalendar, item);
const identifier = calendar.toString();
const constructor = Object.is(this, realTemporalCalendar) ? Calendar : this;
return new constructor(identifier);
}
}
Object.getOwnPropertyNames(realTemporalCalendar.prototype).forEach((name) => {
if (name === 'constructor') return;
const desc = Object.getOwnPropertyDescriptor(realTemporalCalendar.prototype, name);
Object.defineProperty(Calendar.prototype, name, desc);
});

// Do the same for the Temporal.TimeZone constructor and Temporal.TimeZone.from
// to allow only offset time zones and the various aliases for UTC, otherwise
// insecure code might be able to tell something about the version of the host
// system's time zone database.

class TimeZone extends realTemporalTimeZone {
constructor(identifier) {
const matchOffset = /^[+\u2212-][0-2][0-9](?::?[0-5][0-9](?::?[0-5][0-9](?:[.,]\d{1,9})?)?)?$/;
const matchUTC = /^UTC|Etc\/UTC|Etc\/GMT(?:[-+]\d{1,2})?$/;
if (!matchUTC.test(identifier) && !matchOffset.test(identifier)) {
// match error message
throw new RangeError(`Invalid time zone specified: ${identifier}`);
}
super(identifier);
}

static from(item) {
const timeZone = realTimeZoneFrom.call(realTemporalTimeZone, item);
const identifier = timeZone.toString();
const constructor = Object.is(this, realTemporalTimeZone) ? TimeZone : this;
return new constructor(identifier);
}
}
Object.getOwnPropertyNames(realTemporalTimeZone.prototype).forEach((name) => {
if (name === 'constructor') return;
const desc = Object.getOwnPropertyDescriptor(realTemporalTimeZone.prototype, name);
Object.defineProperty(TimeZone.prototype, name, desc);
});

// Override the functions in the Temporal.now namespace using our patched clock,
// calendar, and time zone.

function instant() {
return new Temporal.Instant(clock.epochNs);
}

function plainDateTime(calendarLike, temporalTimeZoneLike = timeZone()) {
const timeZone = TimeZone.from(temporalTimeZoneLike);
const calendar = Calendar.from(calendarLike);
const inst = instant();
return timeZone.getPlainDateTimeFor(inst, calendar);
}

function plainDateTimeISO(temporalTimeZoneLike = timeZone()) {
const timeZone = TimeZone.from(temporalTimeZoneLike);
const calendar = new Calendar('iso8601');
const inst = instant();
return timeZone.getPlainDateTimeFor(inst, calendar);
}

function zonedDateTime(calendarLike, temporalTimeZoneLike = timeZone()) {
const timeZone = TimeZone.from(temporalTimeZoneLike);
const calendar = Calendar.from(calendarLike);
return new Temporal.ZonedDateTime(clock.epochNs, timeZone, calendar);
}

function zonedDateTimeISO(temporalTimeZoneLike = timeZone()) {
const timeZone = TimeZone.from(temporalTimeZoneLike);
const calendar = new Calendar('iso8601');
return new Temporal.ZonedDateTime(clock.epochNs, timeZone, calendar);
}

function plainDate(calendarLike, temporalTimeZoneLike = timeZone()) {
const pdt = plainDateTime(calendarLike, temporalTimeZoneLike);
const f = pdt.getISOFields();
return new Temporal.PlainDate(f.isoYear, f.isoMonth, f.isoDay, f.calendar);
}

function plainDateISO(temporalTimeZoneLike = timeZone()) {
const pdt = plainDateTimeISO(temporalTimeZoneLike);
const f = pdt.getISOFields();
return new Temporal.PlainDate(f.isoYear, f.isoMonth, f.isoDay, f.calendar);
}

function plainTimeISO(temporalTimeZoneLike = timeZone()) {
const pdt = plainDateTimeISO(temporalTimeZoneLike);
const f = pdt.getISOFields();
return new Temporal.PlainTime(
f.isoHour,
f.isoMinute,
f.isoSecond,
f.isoMillisecond,
f.isoMicrosecond,
f.isoNanosecond
);
}

function timeZone() {
return new TimeZone('UTC');
}

// We now have everything we need to lock down Temporal, but if we want the
// insecure code to run in an indistinguishable environment from an unlocked
// Temporal, then we have to do a few more things, such as make sure that
// toString() gives the same result for the patched functions as it would for
// the original functions.

// This example code is not exhaustive, but this is a sample of the concerns
// that a secure environment would have to address.

const realFunctionToString = Function.prototype.toString;
const functionToString = function toString() {
const patchedFunctions = new Map([
[Calendar, realTemporalCalendar],
[Calendar.from, realCalendarFrom],
[instant, realTemporalNow.instant],
[plainDate, realTemporalNow.plainDate],
[plainDateISO, realTemporalNow.plainDateISO],
[plainDateTime, realTemporalNow.plainDateTime],
[plainDateTimeISO, realTemporalNow.plainDateTimeISO],
[plainTimeISO, realTemporalNow.plainTimeISO],
[timeZone, realTemporalNow.timeZone],
[TimeZone, realTemporalTimeZone],
[TimeZone.from, realTimeZoneFrom],
[toString, realFunctionToString],
[zonedDateTime, realTemporalNow.zonedDateTime],
[zonedDateTimeISO, realTemporalNow.zonedDateTimeISO]
]);
if (patchedFunctions.has(this)) {
return realFunctionToString.apply(patchedFunctions.get(this), arguments);
}
return realFunctionToString.apply(this, arguments);
};

// Finally, freeze the Temporal object and all of its properties.
// (Because this is done before any user code runs, we can use Temporal APIs in
// the functions above. Otherwise we'd need to save the original APIs in case
// user code overrode them.)

function deepFreeze(object, path) {
Object.getOwnPropertyNames(object).forEach((name) => {
// Avoid .prototype.constructor endless loop
if (name === 'constructor') return;

const desc = Object.getOwnPropertyDescriptor(object, name);

if (desc.value) {
const value = desc.value;
if (typeof value === 'object' || typeof value === 'function') {
deepFreeze(value, `${path}.${name}`);
}
}
if (desc.get) {
deepFreeze(desc.get, `${path}.get ${name}`);
}
if (desc.set) {
deepFreeze(desc.set, `${path}.set ${name}`);
}
});

return Object.freeze(object);
}

// This is the function that does the actual patching to lock down Temporal. It
// must run before any user code does.

function makeMockTemporal() {
realTemporalTimeZone.from = TimeZone.from;
realTemporalCalendar.from = Calendar.from;
Temporal.Calendar = Calendar;
Temporal.TimeZone = TimeZone;
Temporal.now = {
instant,
plainDateTime,
plainDateTimeISO,
plainDate,
plainDateISO,
plainTimeISO,
timeZone,
zonedDateTime,
zonedDateTimeISO
};
deepFreeze(Temporal, 'Temporal');
Function.prototype.toString = functionToString;
}

// Check that we cannot distinguish the mock Temporal from the real one by
// looking at some metadata; save the original metadata for later
const realTemporalNowPlainDateToString = Temporal.now.plainDate.toString();
const realTemporalNowPlainDateOwnProperties = Object.getOwnPropertyDescriptors(Temporal.now.plainDate);

// After this call, Temporal is locked down.
makeMockTemporal();

// The clock starts at midnight UTC January 1, 1970, and is advanced manually.
assert.equal(Temporal.now.instant().toString(), '1970-01-01T00:00:00Z');
clock.tick(1_000_000_000n);
assert.equal(Temporal.now.instant().toString(), '1970-01-01T00:00:01Z');
clock.tick(86400_000_000_000n);
assert.equal(Temporal.now.instant().toString(), '1970-01-02T00:00:01Z');

// The other functions in the Temporal.now namespace use the same clock.
assert.equal(Temporal.now.plainDateTimeISO().toString(), '1970-01-02T00:00:01');
assert.equal(Temporal.now.plainDateISO().toString(), '1970-01-02');
assert.equal(Temporal.now.plainTimeISO().toString(), '00:00:01');
assert.equal(Temporal.now.zonedDateTimeISO().toString(), '1970-01-02T00:00:01+00:00[UTC]');

// Time zones other than UTC and calendars other than ISO are not provided.
assert.throws(() => Temporal.ZonedDateTime.from('2021-02-12T16:18[America/Vancouver]'), RangeError);
assert.throws(() => Temporal.PlainDate.from('2021-02-12[u-ca-gregory]'), RangeError);

// Constructing unsupported time zones directly doesn't work either.
assert.throws(() => new Temporal.TimeZone('America/Vancouver'), RangeError);
assert.throws(() => Temporal.TimeZone.from('America/Vancouver'), RangeError);
assert.throws(() => new Temporal.Calendar('gregory'), RangeError);
assert.throws(() => Temporal.Calendar.from('gregory'), RangeError);

// UTC, offset time zones, and their aliases are still supported.
assert.equal(new Temporal.TimeZone('-08:00').toString(), '-08:00');
assert.equal(new Temporal.TimeZone('Etc/UTC').toString(), 'UTC');
assert.equal(new Temporal.TimeZone('Etc/GMT+8').toString(), 'Etc/GMT+8');

// Check that our function metadata is equal to what we saved earlier...
assert.equal(Temporal.now.plainDate.toString(), realTemporalNowPlainDateToString);

// ...except take into account that we've frozen the Temporal object.
Object.values(realTemporalNowPlainDateOwnProperties).forEach((desc) => {
desc.configurable = false;
desc.writable = false;
});
assert.deepEqual(Object.getOwnPropertyDescriptors(Temporal.now.plainDate), realTemporalNowPlainDateOwnProperties);
2 changes: 1 addition & 1 deletion polyfill/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"scripts": {
"coverage": "c8 report --reporter html",
"test": "node --no-warnings --experimental-modules --icu-data-dir node_modules/full-icu --loader ./test/resolve.source.mjs ./test/all.mjs",
"test-cookbook": "TEST=all npm run test-cookbook-one && TEST=stockExchangeTimeZone npm run test-cookbook-one",
"test-cookbook": "TEST=all npm run test-cookbook-one && TEST=stockExchangeTimeZone npm run test-cookbook-one && TEST=makeMockTemporal npm run test-cookbook-one",
"test-cookbook-one": "node --no-warnings --experimental-modules --icu-data-dir node_modules/full-icu --loader ./test/resolve.cookbook.mjs ../docs/cookbook/$TEST.mjs",
"test262": "./ci_test.sh",
"codecov:tests": "NODE_V8_COVERAGE=coverage/tmp npm run test && c8 report --reporter=text-lcov > coverage/tests.lcov && codecov -F tests -f coverage/tests.lcov",
Expand Down