polycratia

A strict DER reader needs a calendar, not a clock

· 9 min read

The validity period is the only part of a certificate written as text. Everything else DER encodes is bytes with structural rules (a length, a two's complement integer, a base-128 OID arc), and those rules are mechanical enough that a reader either applies them or visibly does not. notBefore and notAfter are ASCII digits sitting inside a binary format, and that is where leniency collects: none of the minimality machinery that catches a padded integer or a long-form length ever looks inside a string.

So take three documents. One carries 2501011200Z, a time with no seconds. One carries 250101120000+0200, a time with an offset instead of Z. One carries 250230120000Z, February 30. DER defines exactly one spelling of a time and none of these is it. No cryptography is broken by accepting them, which is the problem: each one is an instant that two readers place differently, and the signature over the bytes verifies in both readings. I keep these rules in derstrict, a DER reader whose whole premise is refusing what DER already forbids.

Three bad times, three different layers#

A time without seconds fails as a shape. A reader that accepts it has to supply the seconds. Zero is the obvious supplement and it is still an invention: the writer may have truncated a real 30. The document that reader produces now has a notAfter up to fifty-nine seconds away from the one its peer produced. On a long-lived certificate that is nothing. On a short-lived one, or on a check performed near the boundary, it decides valid against not valid.

An offset fails as a zone. +0200 is a complete, unambiguous instant to a reader that implements offsets. The danger is not ambiguity in the bytes. It is that a reader which does not implement offsets still has to do something, and the usual something is to consume the leading 250101120000 and ignore the tail. One verifier then holds 10:00Z and the other holds 12:00Z. Same bytes, same signature, two instants. A bare local time with no zone at all breaks the same rule the same way, and derstrict reports both as time_not_zulu. So does 250101120000z: the lowercase letter is the kind of difference no human reader counts as one and every parser does.

February 30 fails as a date, and that is a different kind of wrong. It is not an instant two readers place differently, it is not an instant. There is nothing to agree about, only a repair to choose. The standard C library's time normalisation rolls it forward into March, other code clamps it to the 28th, and a reader that does either has read a document nobody wrote and nobody signed. The only safe treatment of a malformed encoding is to stop at it.

That third case is the seam in derstrict's own stated line. The library claims strictness is a property of the encoding and of nothing else: whether a value is a sensible one is a question for a schema, a clock, or a key, and none of those are in scope. Refusing February 30 needs a calendar. So why is the calendar inside the line when the clock is outside it? Because a clock is a second input, a reading taken at the moment of verification, which two verifiers can legitimately take at different moments. A calendar is a function of the digits already in the document. Whether those six digits name a day is decidable from the bytes, so it gets decided here. Whether that day has passed is not, so nothing in the library asks.

The grammar first, the calendar second#

The refusals are named separately, which is the part that matters to a caller: a shape failure and a calendar failure are different bugs in whatever produced the document.

cpp
const refusal cases[] = {
    {0x17, "2501011200Z", error::time_missing_seconds},
    {0x17, "250101120000", error::time_not_zulu},
    {0x17, "250101120000+0200", error::time_not_zulu},
    {0x17, "250101120000z", error::time_not_zulu},
    {0x17, "250101120000.5Z", error::time_fractional_seconds},
    {0x17, "2501011200O0Z", error::time_not_digits},  // the letter, not the digit
    {0x17, "251301120000Z", error::time_out_of_range},
    {0x17, "250100120000Z", error::time_out_of_range},
    {0x17, "250101240000Z", error::time_out_of_range},
};

Sit with the last line for a second. Hour 24 is not a typo, and it is not out of range in every calendar notation: ISO 8601 has 24:00 as the end of a day. DER does not, and a reader that accepts it has to decide whether the instant meant is the last of one day or the first of the next. Both readings are defensible. That is what makes it a refusal rather than a conversion, because where the bytes leave nothing to decide, a reader that decides anyway is inventing a meaning.

The century rule reaches back into the year#

Here is the part that surprised me when I wrote it. To decide whether 0229 is a day, you need the century.

cpp
void a_leap_day_is_read_on_the_gregorian_rule() {
    const auto leap = utc("240229120000Z");
    auto p = over(leap);
    CHECK(p.utc_time().has_value());

    const auto four_hundred = generalized("20000229120000Z");
    auto q = over(four_hundred);
    CHECK(q.generalized_time().has_value());

    const auto century = generalized("21000229120000Z");
    auto r = over(century);
    CHECK(!r.generalized_time().has_value());
    CHECK(r.failure() == error::time_out_of_range);
}

2000 has a February 29 and 2100 does not, by the four-hundred-year rule. A GeneralizedTime writes its century, so the check has what it needs. A UTCTime does not. 000229120000Z cannot be judged at all until the two-digit year has been expanded, and X.690 does not say how to expand it. X.509 does: the window is 1950 to 2049.

cpp
void a_two_digit_year_is_read_on_the_1950_window() {
    const auto older = utc("500101000000Z");
    auto p = over(older);
    const auto early = p.utc_time();
    CHECK(early.has_value());
    if (early) CHECK(early->year == 1950);

    const auto newer = utc("490101000000Z");
    auto q = over(newer);
    const auto late = q.utc_time();
    CHECK(late.has_value());
    if (late) CHECK(late->year == 2049);
}

So a certificate-profile rule turns out to be load-bearing inside a check I just described as purely about encoding. The ordering is forced: the expansion has to happen before the calendar check, or the calendar check runs on digits that do not name a year. I see no version of this that avoids the dependency. A reader that picks a different window dates the same certificate differently from every peer. A reader that skips the expansion and evaluates the leap day on two digits accepts February 29 in 2100, or refuses it in 2000, depending on which way it guessed. Taking the window explicitly and documenting it is the only option that leaves two implementations agreeing.

Stated honestly, then, the boundary is not "no knowledge from outside the document". It is "no second input". A fixed window is a constant. A clock is a reading.

The fraction is not a number#

The same discipline applies to the one piece of a time DER does allow to vary in length.

cpp
void a_fraction_is_handed_back_as_digits() {
    const auto data = generalized("19991231235959.25Z");
    auto p = over(data);

    const auto t = p.generalized_time();
    CHECK(t.has_value());
    if (!t) return;
    CHECK(t->second == 59);
    CHECK(t->has_fraction());
    CHECK(t->fraction_digits == 2u);
    CHECK(t->fraction == data.data() + 17);  // a view into the document
    CHECK(t->fraction[0] == '2');
}

The fraction comes back as the digits it was written as, pointing into the document, with a count. Turning it into a number would require deciding how many digits matter, and that decision belongs to whatever consumes the value, not to the reader every consumer shares. The spelling rules around it are the single-encoding rule applied again: a trailing zero in the fraction is refused as non_minimal_time_fraction, a zero fraction is not written at all because X.690 drops the decimal point along with it, a comma in place of the point is BER's option and not DER's, and a UTCTime has no place for a fraction at all, which is time_fractional_seconds. Every one of those is the same instant with a second spelling available.

What I would do differently#

For years my work has been payment integrations and signed-document workflows: e-signature services built to national digital-signature standards, deal flows where several parties sign in sequence, tokenised asset deals where the token accumulates signed documents and deal state. I used to treat time parsing as a solved detail of whichever library was already linked, and I was wrong in a specific way. The failure mode is never a crash. It is two services, usually in different languages and written by different people, that agree on every byte of a document and disagree about one instant, and the disagreement surfaces as an intermittent "invalid" on one side only, at the boundary of a validity window, under load, months after both sides shipped.

What I do now is what the library does: refuse at the parse, name the refusal so the producer can be fixed, and let the time reader perform exactly one expansion, the century window the profile fixes, with no clamping, no rollover, no rounding. If a real producer in a real integration writes bad times and has to be accommodated, that accommodation belongs in a documented, per-producer exception at the application layer, where somebody owns it, rather than in the shared reader where it quietly becomes everyone's semantics.

A reader's job inside a signed document is to get the same value out as everybody else, not merely to get a value out. The time is where that is hardest, because it is the one field that looks like you could safely read it generously...

react

$ new-project --brief

or email hey@polycratia.com