///|
/// A calendar date and time as carried by `UTCTime` and `GeneralizedTime`.
///
/// `utc_offset_minutes` is the offset from UTC. DER requires the UTC designator
/// (`Z`), so every encoding produced by this package uses an offset of 0; the
/// decoder still accepts the numeric offsets that BER permits.
pub struct Asn1Time {
year : Int
month : Int
day : Int
hour : Int
minute : Int
second : Int
utc_offset_minutes : Int
}
///|
/// Creates a time value. Use `Asn1Time::validate` to check the field ranges.
pub fn Asn1Time::new(
year : Int,
month : Int,
day : Int,
hour : Int,
minute : Int,
second : Int,
utc_offset_minutes? : Int = 0,
) -> Asn1Time {
{ year, month, day, hour, minute, second, utc_offset_minutes, }
}
///|
/// Whether the year is a leap year in the proleptic Gregorian calendar.
pub fn is_leap_year(year : Int) -> Bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
///|
/// Number of days in a month, or 0 when the month is out of range.
pub fn days_in_month(year : Int, month : Int) -> Int {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
4 | 6 | 9 | 11 => 30
2 => if is_leap_year(year) { 29 } else { 28 }
_ => 0
}
}
///|
/// Whether every field is inside its valid range.
pub fn Asn1Time::is_valid(self : Asn1Time) -> Bool {
self.month >= 1 &&
self.month <= 12 &&
self.day >= 1 &&
self.day <= days_in_month(self.year, self.month) &&
self.hour >= 0 &&
self.hour <= 23 &&
self.minute >= 0 &&
self.minute <= 59 &&
self.second >= 0 &&
self.second <= 59 &&
self.utc_offset_minutes >= -12 * 60 &&
self.utc_offset_minutes <= 14 * 60
}
///|
/// Renders the value as `2024-05-06T07:08:09Z`, or with a numeric offset when
/// one is present.
pub fn Asn1Time::to_string(self : Asn1Time) -> String {
let buffer = StringBuilder()
buffer.write_string(pad_digits(self.year, 4))
buffer.write_string("-")
buffer.write_string(pad_digits(self.month, 2))
buffer.write_string("-")
buffer.write_string(pad_digits(self.day, 2))
buffer.write_string("T")
buffer.write_string(pad_digits(self.hour, 2))
buffer.write_string(":")
buffer.write_string(pad_digits(self.minute, 2))
buffer.write_string(":")
buffer.write_string(pad_digits(self.second, 2))
if self.utc_offset_minutes == 0 {
buffer.write_string("Z")
} else {
let offset = self.utc_offset_minutes
buffer.write_string(if offset < 0 { "-" } else { "+" })
let magnitude = if offset < 0 { -offset } else { offset }
buffer.write_string(pad_digits(magnitude / 60, 2))
buffer.write_string(":")
buffer.write_string(pad_digits(magnitude % 60, 2))
}
buffer.to_string()
}
///|
/// Formats a non-negative integer with exactly `width` decimal digits.
fn pad_digits(value : Int, width : Int) -> String {
let digits : Array[Byte] = []
let mut divisor = 1
for _ in 0..<(width - 1) {
divisor = divisor * 10
}
let mut remaining = value
for _ in 0.. Int raise DerError {
let mut value = 0
for i in start..<(start + count) {
let code = text.code_unit_at(i).to_int()
if code < 0x30 || code > 0x39 {
raise InvalidValue("time value must contain ASCII digits")
}
value = value * 10 + (code - 0x30)
}
value
}
///|
/// Parses the trailing zone designator, returning the offset in minutes.
fn parse_zone(text : String, start : Int) -> Int raise DerError {
let length = text.length()
if start + 1 == length {
if text.code_unit_at(start).to_int() == 0x5A {
return 0
}
raise InvalidValue("time value must end with 'Z' or a numeric offset")
}
if start + 5 == length {
let sign = text.code_unit_at(start).to_int()
let hours = parse_digits(text, start + 1, 2)
let minutes = parse_digits(text, start + 3, 2)
if hours > 14 || minutes > 59 {
raise InvalidValue("time zone offset is out of range")
}
let total = hours * 60 + minutes
if sign == 0x2B {
total
} else if sign == 0x2D {
-total
} else {
raise InvalidValue("invalid time zone sign")
}
} else {
raise InvalidValue("invalid time zone designator")
}
}
///|
/// Decodes a `UTCTime`. Two-digit years are mapped to 1950-2049, as required by
/// RFC 5280.
pub fn decode_utc_time(content : BytesView) -> Asn1Time raise DerError {
let text = decode_ascii(content, "UTCTime")
let length = text.length()
if length != 13 && length != 17 {
raise InvalidValue("UTCTime must be YYMMDDHHMMSSZ or YYMMDDHHMMSS+hhmm")
}
let short_year = parse_digits(text, 0, 2)
let time = {
year: if short_year >= 50 {
1900 + short_year
} else {
2000 + short_year
},
month: parse_digits(text, 2, 2),
day: parse_digits(text, 4, 2),
hour: parse_digits(text, 6, 2),
minute: parse_digits(text, 8, 2),
second: parse_digits(text, 10, 2),
utc_offset_minutes: parse_zone(text, 12),
}
if !time.is_valid() {
raise InvalidValue("UTCTime fields are out of range")
}
time
}
///|
/// Decodes a `GeneralizedTime` with seconds present and no fractional seconds,
/// which is the only form DER and RFC 5280 allow.
pub fn decode_generalized_time(content : BytesView) -> Asn1Time raise DerError {
let text = decode_ascii(content, "GeneralizedTime")
let length = text.length()
if length != 15 && length != 19 {
raise InvalidValue(
"GeneralizedTime must be YYYYMMDDHHMMSSZ or YYYYMMDDHHMMSS+hhmm",
)
}
let time = {
year: parse_digits(text, 0, 4),
month: parse_digits(text, 4, 2),
day: parse_digits(text, 6, 2),
hour: parse_digits(text, 8, 2),
minute: parse_digits(text, 10, 2),
second: parse_digits(text, 12, 2),
utc_offset_minutes: parse_zone(text, 14),
}
if !time.is_valid() {
raise InvalidValue("GeneralizedTime fields are out of range")
}
time
}
///|
/// Encodes a `UTCTime`. DER requires years in 1950-2049 and the `Z` designator.
pub fn encode_utc_time(value : Asn1Time) -> Bytes raise DerError {
if !value.is_valid() {
raise InvalidValue("cannot encode an invalid time")
}
if value.year < 1950 || value.year > 2049 {
raise InvalidValue("UTCTime can only represent years 1950-2049")
}
if value.utc_offset_minutes != 0 {
raise InvalidValue("DER UTCTime must use the 'Z' designator")
}
let text = pad_digits(value.year % 100, 2) +
pad_digits(value.month, 2) +
pad_digits(value.day, 2) +
pad_digits(value.hour, 2) +
pad_digits(value.minute, 2) +
pad_digits(value.second, 2) +
"Z"
encode_universal(TAG_UTC_TIME, @utf8.encode(text)[:])
}
///|
/// Encodes a `GeneralizedTime`. DER requires the `Z` designator.
pub fn encode_generalized_time(value : Asn1Time) -> Bytes raise DerError {
if !value.is_valid() {
raise InvalidValue("cannot encode an invalid time")
}
if value.utc_offset_minutes != 0 {
raise InvalidValue("DER GeneralizedTime must use the 'Z' designator")
}
let text = pad_digits(value.year, 4) +
pad_digits(value.month, 2) +
pad_digits(value.day, 2) +
pad_digits(value.hour, 2) +
pad_digits(value.minute, 2) +
pad_digits(value.second, 2) +
"Z"
encode_universal(TAG_GENERALIZED_TIME, @utf8.encode(text)[:])
}
///|
/// Encodes a time as `UTCTime` when the year is in 1950-2049 and as
/// `GeneralizedTime` otherwise, which is what X.509 profile requires.
pub fn encode_time(value : Asn1Time) -> Bytes raise DerError {
if value.year >= 1950 && value.year <= 2049 {
encode_utc_time(value)
} else {
encode_generalized_time(value)
}
}