Skip to content

Commit d8779be

Browse files
authored
Fix: UTCTime Issues (#47)
2 parents 6675eb1 + 8b71d09 commit d8779be

2 files changed

Lines changed: 142 additions & 62 deletions

File tree

src/utils.rs

Lines changed: 98 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -52,25 +52,34 @@ pub(crate) fn get_words_from_big_int(data: BigInt) -> (bool, Vec<u64>) {
5252
#[allow(deprecated)]
5353
pub(crate) fn get_utc_date_time_from_asn1_milli<T: AsRef<[u8]>>(data: T) -> Result<DateTime<Utc>> {
5454
let mut decoder = rasn::ber::de::Decoder::new(data.as_ref(), DecoderOptions::ber());
55-
let (decoded, format) = match data.as_ref().first().unwrap_or(&0) {
55+
let (decoded, format, is_utc_time) = match data.as_ref().first().unwrap_or(&0) {
5656
0x17 => (
5757
Utf8String::decode_with_tag(&mut decoder, Tag::UTC_TIME),
5858
ASN1_DATE_TIME_UTC_FORMAT,
59+
true,
5960
),
6061
0x18 => (
6162
Utf8String::decode_with_tag(&mut decoder, Tag::GENERALIZED_TIME),
6263
ASN1_DATE_TIME_GENERAL_FORMAT_WITH_MS,
64+
false,
6365
),
6466
_ => bail!(ASN1NAPIError::MalformedData),
6567
};
6668

6769
if let Ok(decoded) = decoded {
6870
if let Some(offset) = FixedOffset::east_opt(0) {
69-
Ok(DateTime::<FixedOffset>::from_utc(
70-
NaiveDateTime::parse_from_str(&decoded, format)?,
71-
offset,
72-
)
73-
.with_timezone(&Utc))
71+
let mut naive = NaiveDateTime::parse_from_str(&decoded, format)?;
72+
73+
// RFC 5280 §4.1.2.5.1: UTCTime pivots at 50 (>= 50 → 19xx),
74+
// but chrono's %y pivots at 70. Correct the 50-69 range.
75+
// See `<https://github.com/chronotope/chrono/issues/1152>`
76+
if is_utc_time && naive.year() >= 2050 {
77+
naive = naive
78+
.with_year(naive.year() - 100)
79+
.ok_or(ASN1NAPIError::MalformedData)?;
80+
}
81+
82+
Ok(DateTime::<FixedOffset>::from_utc(naive, offset).with_timezone(&Utc))
7483
} else {
7584
bail!(ASN1NAPIError::MalformedData)
7685
}
@@ -311,7 +320,8 @@ pub(crate) fn header_length(data: &[u8]) -> Result<usize, &'static str> {
311320

312321
#[cfg(test)]
313322
mod test {
314-
use chrono::{TimeZone, Utc};
323+
use anyhow::Result;
324+
use chrono::{Datelike, TimeZone, Utc};
315325
use num_bigint::BigInt;
316326

317327
use crate::utils::get_utf16_from_string;
@@ -321,56 +331,106 @@ mod test {
321331
use super::get_utc_date_time_from_asn1_milli;
322332
use super::get_words_from_big_int;
323333

334+
fn build_time_der(tag: u8, time_str: &str) -> Vec<u8> {
335+
let bytes = time_str.as_bytes();
336+
let mut der = Vec::with_capacity(2 + bytes.len());
337+
der.push(tag);
338+
der.push(bytes.len() as u8);
339+
der.extend_from_slice(bytes);
340+
der
341+
}
342+
343+
fn utc_time_der(time_str: &str) -> Vec<u8> {
344+
build_time_der(0x17, time_str)
345+
}
346+
347+
fn generalized_time_der(time_str: &str) -> Vec<u8> {
348+
build_time_der(0x18, time_str)
349+
}
350+
324351
#[test]
325352
fn test_get_utf16_from_string() {
326353
assert_eq!(get_utf16_from_string("test"), vec![0x74, 0x65, 0x73, 0x74]);
327354
}
328355

329356
#[test]
330-
fn test_get_oid_elements_from_string() {
331-
assert_eq!(
332-
get_oid_elements_from_string("2.5.4.5").unwrap(),
333-
vec![2, 5, 4, 5]
334-
);
335-
}
357+
fn test_oid_roundtrip() -> Result<()> {
358+
let cases: &[(&str, &[u32])] = &[("2.5.4.5", &[2, 5, 4, 5])];
359+
360+
for (string, elements) in cases {
361+
assert_eq!(
362+
get_oid_elements_from_string(string)?,
363+
*elements,
364+
"parse {string}"
365+
);
366+
assert_eq!(
367+
get_string_from_oid_elements(*elements)?,
368+
*string,
369+
"format {elements:?}"
370+
);
371+
}
336372

337-
#[test]
338-
fn test_get_string_from_oid_elements() {
339-
assert_eq!(
340-
get_string_from_oid_elements([2, 5, 4, 5]).unwrap(),
341-
"2.5.4.5"
342-
);
373+
Ok(())
343374
}
344375

345376
#[test]
346377
fn test_get_words_from_big_int() {
347-
let input = BigInt::from(18591708106338011145_i128);
348-
let (negative, words) = get_words_from_big_int(input);
349-
350-
assert!(!negative);
351-
assert_eq!(words, vec![0x203040506070809, 0x01]);
352-
353-
let input = BigInt::from(-18591708106338011145_i128);
354-
let (negative, words) = get_words_from_big_int(input);
378+
let cases: &[(i128, bool, &[u64])] = &[
379+
(18591708106338011145, false, &[0x203040506070809, 0x01]),
380+
(-18591708106338011145, true, &[0x203040506070809, 0x01]),
381+
];
355382

356-
assert!(negative);
357-
assert_eq!(words, vec![0x203040506070809, 0x01]);
383+
for (value, expected_negative, expected_words) in cases {
384+
let (negative, words) = get_words_from_big_int(BigInt::from(*value));
385+
assert_eq!(negative, *expected_negative, "sign for {value}");
386+
assert_eq!(words.as_slice(), *expected_words, "words for {value}");
387+
}
358388
}
359389

360390
#[test]
361-
fn test_get_utc_date_time_from_asn1_milli() {
362-
let date = Utc.timestamp_millis_opt(1655921880210).unwrap();
363-
let input = [
364-
24, 19, 50, 48, 50, 50, 48, 54, 50, 50, 49, 56, 49, 56, 48, 48, 46, 50, 49, 48, 90,
391+
fn test_utc_time_rfc5280_pivot() -> Result<()> {
392+
// RFC 5280 §4.1.2.5.1: >= 50 → 19xx, < 50 → 20xx
393+
let cases: &[(&str, i32)] = &[
394+
("000601120000Z", 2000), // below pivot, 21st century
395+
("490601120000Z", 2049), // boundary: last year below pivot
396+
("500601120000Z", 1950), // boundary: first year at pivot
397+
("690101000000Z", 1969), // above pivot, chrono disagrees (bug case)
398+
("700601120000Z", 1970), // at chrono's pivot, both agree
399+
("990601120000Z", 1999), // max 2-digit year
365400
];
366401

367-
assert_eq!(get_utc_date_time_from_asn1_milli(input).unwrap(), date);
402+
for (time_str, expected_year) in cases {
403+
let result = get_utc_date_time_from_asn1_milli(utc_time_der(time_str))?;
404+
assert_eq!(
405+
result.year(),
406+
*expected_year,
407+
"UTCTime {time_str} should decode to year {expected_year}"
408+
);
409+
}
410+
411+
Ok(())
412+
}
368413

369-
let date = Utc.with_ymd_and_hms(2022, 9, 26, 10, 0, 0).unwrap();
370-
let input = [
371-
24, 15, 50, 48, 50, 50, 48, 57, 50, 54, 49, 48, 48, 48, 48, 48, 90,
414+
#[test]
415+
fn test_generalized_time_decode() -> Result<()> {
416+
let cases = [
417+
(
418+
"20220622181800.210Z",
419+
Utc.timestamp_millis_opt(1655921880210).single(),
420+
),
421+
(
422+
"20220926100000Z",
423+
Utc.with_ymd_and_hms(2022, 9, 26, 10, 0, 0).single(),
424+
),
372425
];
373426

374-
assert_eq!(get_utc_date_time_from_asn1_milli(input).unwrap(), date);
427+
for (time_str, expected) in cases {
428+
let expected =
429+
expected.ok_or_else(|| anyhow::anyhow!("invalid test date {time_str}"))?;
430+
let result = get_utc_date_time_from_asn1_milli(generalized_time_der(time_str))?;
431+
assert_eq!(result, expected, "GeneralizedTime {time_str}");
432+
}
433+
434+
Ok(())
375435
}
376436
}

tests/date.spec.ts

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,53 @@ import test from 'ava';
33

44
import * as lib from '..';
55

6-
const TEST_DATES: { in: Date; out?: Date; }[] = [
7-
{ in: new Date(0) },
8-
{ in: new Date('2022-09-26T10:00:00.000+00:00') },
9-
{ in: new Date('2022-09-26T10:10:32.420+00:00'), out: new Date('2022-09-26T10:10:32.000+00:00') },
10-
{ in: new Date('2052-09-26T10:10:32.420+00:00') },
11-
]
12-
13-
const TEST_DATES_ASN1 = [
14-
new Uint8Array([0x17, 0x0d, 0x37, 0x30, 0x30, 0x31, 0x30, 0x31, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x5a]).buffer,
15-
new Uint8Array([0x17, 0x0d, 0x32, 0x32, 0x30, 0x39, 0x32, 0x36, 0x31, 0x30, 0x30, 0x30, 0x30, 0x30, 0x5a]).buffer,
16-
new Uint8Array([0x17, 0x0d, 0x32, 0x32, 0x30, 0x39, 0x32, 0x36, 0x31, 0x30, 0x31, 0x30, 0x33, 0x32, 0x5a]).buffer,
17-
new Uint8Array([0x18, 0x13, 0x32, 0x30, 0x35, 0x32, 0x30, 0x39, 0x32, 0x36, 0x31, 0x30, 0x31, 0x30, 0x33, 0x32, 0x2e, 0x34, 0x32, 0x30, 0x5a]).buffer
6+
// Each case pairs a JS Date with its expected DER encoding.
7+
// `out` overrides the expected decode result when encoding is lossy (e.g. ms truncation).
8+
// RFC 5280 §4.1.2.5.1: UTCTime (0x17) for years < 2050, GeneralizedTime (0x18) for >= 2050.
9+
const TEST_CASES: { in: Date; out?: Date; der: ArrayBuffer }[] = [
10+
{
11+
in: new Date(0),
12+
der: new Uint8Array([0x17, 0x0d, 0x37, 0x30, 0x30, 0x31, 0x30, 0x31, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x5a]).buffer,
13+
},
14+
{
15+
in: new Date('2022-09-26T10:00:00.000+00:00'),
16+
der: new Uint8Array([0x17, 0x0d, 0x32, 0x32, 0x30, 0x39, 0x32, 0x36, 0x31, 0x30, 0x30, 0x30, 0x30, 0x30, 0x5a]).buffer,
17+
},
18+
{
19+
in: new Date('2022-09-26T10:10:32.420+00:00'),
20+
out: new Date('2022-09-26T10:10:32.000+00:00'),
21+
der: new Uint8Array([0x17, 0x0d, 0x32, 0x32, 0x30, 0x39, 0x32, 0x36, 0x31, 0x30, 0x31, 0x30, 0x33, 0x32, 0x5a]).buffer,
22+
},
23+
{
24+
in: new Date('2052-09-26T10:10:32.420+00:00'),
25+
der: new Uint8Array([0x18, 0x13, 0x32, 0x30, 0x35, 0x32, 0x30, 0x39, 0x32, 0x36, 0x31, 0x30, 0x31, 0x30, 0x33, 0x32, 0x2e, 0x34, 0x32, 0x30, 0x5a]).buffer,
26+
},
27+
// RFC 5280 pivot: UTCTime 2-digit year >= 50 → 19xx, < 50 → 20xx
28+
{
29+
in: new Date('1950-06-01T12:00:00Z'),
30+
der: new Uint8Array([0x17, 0x0d, 0x35, 0x30, 0x30, 0x36, 0x30, 0x31, 0x31, 0x32, 0x30, 0x30, 0x30, 0x30, 0x5a]).buffer,
31+
},
32+
{
33+
in: new Date('1969-01-01T00:00:00Z'),
34+
der: new Uint8Array([0x17, 0x0d, 0x36, 0x39, 0x30, 0x31, 0x30, 0x31, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x5a]).buffer,
35+
},
36+
{
37+
in: new Date('2049-06-01T12:00:00Z'),
38+
der: new Uint8Array([0x17, 0x0d, 0x34, 0x39, 0x30, 0x36, 0x30, 0x31, 0x31, 0x32, 0x30, 0x30, 0x30, 0x30, 0x5a]).buffer,
39+
},
1840
]
1941

2042
test('JS Date to ASN1 conversion', (t) => {
21-
TEST_DATES.forEach((v, i) => {
22-
t.deepEqual(lib.JStoASN1(v.in).toBER(), TEST_DATES_ASN1[i])
43+
TEST_CASES.forEach((v) => {
44+
t.deepEqual(lib.JStoASN1(v.in).toBER(), v.der)
2345
})
2446
})
2547

2648
test('ASN1 to Js Date conversion from byte code', (t) => {
27-
TEST_DATES_ASN1.forEach((v, i) => {
28-
const obj = new lib.ASN1Decoder(v)
29-
const expected = TEST_DATES[i].out ?? TEST_DATES[i].in
30-
t.deepEqual(obj.intoDate(), expected)
31-
t.deepEqual(lib.ASN1toJS(v), expected)
49+
TEST_CASES.forEach((v) => {
50+
const expected = v.out ?? v.in
51+
t.deepEqual(new lib.ASN1Decoder(v.der).intoDate(), expected)
52+
t.deepEqual(lib.ASN1toJS(v.der), expected)
3253
})
3354
})
3455

@@ -41,10 +62,9 @@ test('ASN1 to Js Date conversion from base64', (t) => {
4162
})
4263

4364
test('ASN1 to Js Date conversion round trip', (t) => {
44-
TEST_DATES_ASN1.forEach((v, i) => {
45-
const js = new lib.ASN1Decoder(v)
46-
47-
t.deepEqual(js.intoDate(), TEST_DATES[i].out ?? TEST_DATES[i].in)
48-
t.deepEqual(lib.JStoASN1(lib.ASN1toJS(v)).toBER(), TEST_DATES_ASN1[i])
65+
TEST_CASES.forEach((v) => {
66+
const expected = v.out ?? v.in
67+
t.deepEqual(new lib.ASN1Decoder(v.der).intoDate(), expected)
68+
t.deepEqual(lib.JStoASN1(lib.ASN1toJS(v.der)).toBER(), v.der)
4969
})
5070
})

0 commit comments

Comments
 (0)