diff --git a/lib/datekeys.dart b/lib/datekeys.dart index 774fc94..9a870b9 100644 --- a/lib/datekeys.dart +++ b/lib/datekeys.dart @@ -2,9 +2,10 @@ /// DateKeys Access Key (`.dkk`), as `datekeys-go` and `datekeys-ts` implement /// them, checked against the same test data. /// -/// Stage 0 of docs/PLAN_dart.md, the package and its test data, and from -/// stage 1 the normative errors of spec §69, byte helpers and the CBOR -/// profile of spec §58. The protocol arrives stage by stage. +/// Stages 0 and 1 of docs/PLAN_dart.md: the package and its test data, the +/// normative errors of spec §69, byte helpers and the CBOR profile of spec +/// §58. DER, internal in the Go reference, is internal here too. The protocol +/// arrives stage by stage. library; export 'src/bytes.dart' diff --git a/lib/src/der.dart b/lib/src/der.dart new file mode 100644 index 0000000..3475c26 --- /dev/null +++ b/lib/src/der.dart @@ -0,0 +1,422 @@ +/// A check that bytes are one element in the Distinguished Encoding Rules of +/// X.690, as package internal/der of datekeys-go at v0.12, which spec v0.11 +/// §29.10 asks of a CMS signature before it looks inside it: definite and +/// minimal lengths, no high tag numbers, no constructed form of a type that +/// DER only has primitive, canonical BOOLEAN, INTEGER, NULL, OBJECT +/// IDENTIFIER, BIT STRING, UTCTime and GeneralizedTime, and no bytes after +/// the element. A signature that two implementations read the same must not +/// depend on what a lenient reader accepts. +/// +/// The order of the elements of a SET OF cannot be checked without a schema: +/// [setOfSorted] does it for the callers that know theirs. +/// +/// Internal, as in Go: lib/datekeys.dart does not export it. The functions +/// keep the names of the Go package, for an import with a prefix, as +/// `import 'der.dart' as der;` and `der.check(b)`. +library; + +import 'dart:typed_data'; + +import 'bytes.dart'; + +/// Bytes that are not the DER element asked for. The message says what is +/// wrong, in the words of the reference, and starts with `der: `. +final class DerException implements Exception { + /// An exception whose message is [message]. + const DerException(this.message); + + /// The text of the error, as Go's err.Error() of the reference. + final String message; + + @override + String toString() => message; +} + +const _maxDepth = 32; + +/// Throws a [DerException] unless [b] is exactly one DER element. +void check(Uint8List b) { + final n = _check(b, 0, b.length, 0); + if (n != b.length) { + throw DerException('der: ${b.length - n} bytes after the element'); + } +} + +/// The identifier octet of the DER element [b], which must be constructed, +/// and the encodings of its children, views of [b]. It assumes [check] +/// passed. +({int id, List children}) split(Uint8List b) { + if (b.isEmpty || b[0] & 0x20 == 0) { + throw const DerException('der: not a constructed element'); + } + final (hl, cl) = _header(b, 0, b.length); + final end = hl + cl; + final children = []; + for (var p = hl; p < end;) { + final (h, c) = _header(b, p, end); + children.add(Uint8List.sublistView(b, p, p + h + c)); + p += h + c; + } + return (id: b[0], children: children); +} + +/// The content octets of the DER element [b], a view of it. +Uint8List content(Uint8List b) { + final (hl, cl) = _header(b, 0, b.length); + return Uint8List.sublistView(b, hl, hl + cl); +} + +/// Whether the encodings are in ascending order of their bytes, as DER +/// requires of the elements of a SET OF (X.690 11.6). Equal elements may +/// repeat, side by side: X.690 does not forbid it, and a time-stamping +/// authority may send its certificate twice. Whoever counts the elements of a +/// SET OF decides what a repetition means. +bool setOfSorted(List> elems) { + for (var i = 1; i < elems.length; i++) { + if (compareBytes(elems[i - 1], elems[i]) > 0) return false; + } + return true; +} + +/// Reads a UTCTime or a GeneralizedTime element as DER writes them (X.690 +/// 11.7 and 11.8): YYMMDDHHMMSSZ, with the years 50 to 99 in the 20th century +/// (RFC 5280 4.1.2.5.1), or YYYYMMDDHHMMSS, an optional fraction of seconds +/// without a trailing zero, and Z. A date or a time that does not exist, a +/// second 60 included, is refused. [fraction] reports whether a +/// GeneralizedTime has one; digits of it beyond the nanosecond are dropped, +/// as Go drops them. +({DerTime time, bool fraction}) parseTime(Uint8List b) { + if (b.length < 2 || (b[0] != 0x17 && b[0] != 0x18)) { + throw const DerException('der: not a UTCTime or a GeneralizedTime'); + } + final c = content(b); + return _parseTime(b[0], c, 0, c.length); +} + +/// A time of DER, in UTC and exact to the nanosecond, as Go's time.Time of +/// the reference: Dart's DateTime holds microseconds at most. +final class DerTime implements Comparable { + const DerTime._( + this.year, + this.month, + this.day, + this.hour, + this.minute, + this.second, + this.nanosecond, + ); + + /// The year, 0 to 9999. + final int year; + + /// The month, 1 to 12. + final int month; + + /// The day of the month, from 1. + final int day; + + /// The hour, 0 to 23. + final int hour; + + /// The minute, 0 to 59. + final int minute; + + /// The second, 0 to 59. + final int second; + + /// The nanosecond within the second, 0 to 999 999 999. + final int nanosecond; + + /// The seconds since 1970-01-01T00:00:00Z, negative before, as Go's + /// Time.Unix. Every value is below 2^53, so it is exact on every platform. + int get unixSeconds => + _daysFromCivil(year, month, day) * 86400 + + hour * 3600 + + minute * 60 + + second; + + /// Whether this time is before [other]. + bool isBefore(DerTime other) => compareTo(other) < 0; + + /// Whether this time is after [other]. + bool isAfter(DerTime other) => compareTo(other) > 0; + + @override + int compareTo(DerTime other) { + final s = unixSeconds.compareTo(other.unixSeconds); + return s != 0 ? s : nanosecond.compareTo(other.nanosecond); + } + + @override + bool operator ==(Object other) => + other is DerTime && + other.unixSeconds == unixSeconds && + other.nanosecond == nanosecond; + + @override + int get hashCode => Object.hash(unixSeconds, nanosecond); + + /// The time in RFC 3339, as Go's time.RFC3339Nano writes a time in UTC, + /// such as `2024-02-29T12:00:00.125Z`. + @override + String toString() { + String two(int v) => v.toString().padLeft(2, '0'); + var fraction = ''; + if (nanosecond != 0) { + var digits = nanosecond.toString().padLeft(9, '0'); + while (digits.endsWith('0')) { + digits = digits.substring(0, digits.length - 1); + } + fraction = '.$digits'; + } + return '${year.toString().padLeft(4, '0')}-${two(month)}-${two(day)}' + 'T${two(hour)}:${two(minute)}:${two(second)}${fraction}Z'; + } +} + +// The days from 1970-01-01 to the date, in the proleptic Gregorian calendar +// of Go's time package (days_from_civil of Howard Hinnant). +int _daysFromCivil(int year, int month, int day) { + final y = month <= 2 ? year - 1 : year; + final era = (y >= 0 ? y : y - 399) ~/ 400; + final yearOfEra = y - era * 400; + final dayOfYear = + (153 * (month > 2 ? month - 3 : month + 9) + 2) ~/ 5 + day - 1; + final dayOfEra = + yearOfEra * 365 + yearOfEra ~/ 4 - yearOfEra ~/ 100 + dayOfYear; + return era * 146097 + dayOfEra - 719468; +} + +int _daysInMonth(int year, int month) { + if (month == 2) { + final leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + return leap ? 29 : 28; + } + return const [31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]; +} + +const _bad = DerException('der: a time that is not in the form of DER'); + +// parseTime of Go on the content s[start:end] of an element of tag 0x17 or +// 0x18, read as bytes. +({DerTime time, bool fraction}) _parseTime( + int tag, + Uint8List s, + int start, + int end, +) { + if (end - start < 13 || s[end - 1] != 0x5a) throw _bad; // Z + // The body is s[start:bodyEnd], without the Z, and the fraction of seconds + // s[fracStart:end - 1], empty when there is none. + var bodyEnd = end - 1; + var fracStart = end - 1; + final int year; + if (tag == 0x17) { + if (bodyEnd - start != 12 || !_digits(s, start, bodyEnd)) throw _bad; + final yy = _atoi(s, start, start + 2) + 1900; + year = yy < 1950 ? yy + 100 : yy; + } else { + final dot = _indexOf(s, start, bodyEnd, 0x2e); // . + if (dot >= 0) { + fracStart = dot + 1; + if (fracStart == end - 1 || + s[end - 2] == 0x30 || + !_digits(s, fracStart, end - 1)) { + throw _bad; + } + bodyEnd = dot; + } + if (bodyEnd - start != 14 || !_digits(s, start, bodyEnd)) throw _bad; + year = _atoi(s, start, start + 4); + } + // MMDDHHMMSS, the last ten digits of the body. + final p = bodyEnd - 10; + final month = _atoi(s, p, p + 2); + final day = _atoi(s, p + 2, p + 4); + final hour = _atoi(s, p + 4, p + 6); + final minute = _atoi(s, p + 6, p + 8); + final second = _atoi(s, p + 8, p + 10); + if (month < 1 || + month > 12 || + day < 1 || + hour > 23 || + minute > 59 || + second > 59 || + day > _daysInMonth(year, month)) { + throw const DerException('der: a date or a time that does not exist'); + } + final fracLength = end - 1 - fracStart; + var nanos = 0; + for (var i = 0; i < 9; i++) { + nanos *= 10; + if (i < fracLength) nanos += s[fracStart + i] - 0x30; + } + return ( + time: DerTime._(year, month, day, hour, minute, second, nanos), + fraction: fracLength > 0, + ); +} + +// Whether s[start:end] is not empty and only ASCII digits. +bool _digits(Uint8List s, int start, int end) { + for (var i = start; i < end; i++) { + if (s[i] < 0x30 || s[i] > 0x39) return false; + } + return end > start; +} + +int _atoi(Uint8List s, int start, int end) { + var n = 0; + for (var i = start; i < end; i++) { + n = n * 10 + s[i] - 0x30; + } + return n; +} + +int _indexOf(Uint8List s, int start, int end, int byte) { + for (var i = start; i < end; i++) { + if (s[i] == byte) return i; + } + return -1; +} + +// The length of the identifier and length octets of the element at +// b[start:end], and the length of its content, which must fit in it. +(int, int) _header(Uint8List b, int start, int end) { + if (end - start < 2) throw const DerException('der: truncated element'); + if (b[start] & 0x1f == 0x1f) { + throw const DerException('der: a tag number of 31 or more'); + } + final l = b[start + 1]; + final int headerLen; + final int contentLen; + if (l < 0x80) { + headerLen = 2; + contentLen = l; + } else if (l == 0x80) { + throw const DerException('der: an indefinite length'); + } else if (l == 0xff) { + throw const DerException('der: a length of 0xff'); + } else { + final n = l & 0x7f; + if (n > 4 || end - start < 2 + n) { + throw const DerException('der: a length that does not fit'); + } + if (b[start + 2] == 0) { + throw const DerException('der: a length with a leading zero'); + } + // At most four bytes, below 2^32: exact without a shift. + var v = 0; + for (var i = 0; i < n; i++) { + v = v * 256 + b[start + 2 + i]; + } + if (v < 0x80) { + throw const DerException('der: a long form for a length under 128'); + } + headerLen = 2 + n; + contentLen = v; + } + if (contentLen > end - start - headerLen) { + throw const DerException('der: an element longer than its container'); + } + return (headerLen, contentLen); +} + +// Validates the element at the start of b[start:end] and returns its length. +int _check(Uint8List b, int start, int end, int depth) { + if (depth > _maxDepth) throw const DerException('der: nested too deep'); + final (hl, cl) = _header(b, start, end); + final id = b[start]; + final cls = id >> 6; + final constructed = id & 0x20 != 0; + final tag = id & 0x1f; + final contentStart = start + hl; + final contentEnd = contentStart + cl; + if (constructed) { + if (cls == 0 && tag != 16 && tag != 17) { + throw DerException('der: constructed form of the universal type $tag'); + } + for (var p = contentStart; p < contentEnd;) { + p += _check(b, p, contentEnd, depth + 1); + } + return hl + cl; + } + if (cls == 0) _checkPrimitive(tag, b, contentStart, contentEnd); + return hl + cl; +} + +// Checks the content c[start:end] of a primitive element of the universal +// class. +void _checkPrimitive(int tag, Uint8List c, int start, int end) { + final length = end - start; + switch (tag) { + case 1: // BOOLEAN + if (length != 1 || (c[start] != 0 && c[start] != 0xff)) { + throw const DerException('der: a BOOLEAN that is not 00 or FF'); + } + case 2 || 10: // INTEGER, ENUMERATED + if (length == 0) throw const DerException('der: an empty INTEGER'); + if (length > 1 && + ((c[start] == 0 && c[start + 1] & 0x80 == 0) || + (c[start] == 0xff && c[start + 1] & 0x80 != 0))) { + throw const DerException('der: an INTEGER that is not minimal'); + } + case 3: // BIT STRING + if (length == 0 || c[start] > 7 || (length == 1 && c[start] != 0)) { + throw const DerException('der: a malformed BIT STRING'); + } + if (length > 1 && + c[start] != 0 && + c[end - 1] & ((1 << c[start]) - 1) != 0) { + throw const DerException( + 'der: a BIT STRING with unused bits that are not zero', + ); + } + case 5: // NULL + if (length != 0) throw const DerException('der: a NULL with content'); + case 6: // OBJECT IDENTIFIER + if (length == 0 || c[end - 1] & 0x80 != 0) { + throw const DerException('der: a malformed OBJECT IDENTIFIER'); + } + var first = true; + for (var i = start; i < end; i++) { + if (first && c[i] == 0x80) { + throw const DerException( + 'der: an OBJECT IDENTIFIER with a leading 0x80 in a subidentifier', + ); + } + first = c[i] & 0x80 == 0; + } + case 23 || 24: // UTCTime, GeneralizedTime + _parseTime(tag, c, start, end); + case 4 || + 7 || + 12 || + 18 || + 19 || + 20 || + 21 || + 22 || + 25 || + 26 || + 27 || + 28 || + 30: + // OCTET STRING, ObjectDescriptor and the restricted character string + // types: DER writes them primitive (X.690 10.2), with their content as + // it is. A name may hold any of them, a NumericString among them, as + // the certificates of some countries do: what is not text decides at + // the text of the name (spec §29.10), not here. + break; + case 16 || 17: + throw DerException('der: the universal type $tag in primitive form'); + default: + // 0 is the end of contents of BER, and the rest are types that no + // certificate, signature or token of the profile has: REAL, + // RELATIVE-OID, TIME and the reserved tags, whose DER has rules of + // their own that this module does not check. + throw DerException( + 'der: the universal type $tag, which the profile does not use', + ); + } +} diff --git a/test/der_support.dart b/test/der_support.dart new file mode 100644 index 0000000..251cc17 --- /dev/null +++ b/test/der_support.dart @@ -0,0 +1,59 @@ +// Helpers of der_test.dart and der_vectors_test.dart: the walk of +// FuzzDERCheck of datekeys-go, and the mutations of a fuzzer. + +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:datekeys/datekeys.dart' show concatBytes, toHex; +import 'package:datekeys/src/der.dart' as der; +import 'package:test/test.dart'; + +/// Checks that split, content and parseTime read the element [b], which +/// check accepted, element by element: the children of a constructed element +/// are its content exactly, a primitive one is not split, and a time of the +/// universal class is read by parseTime. +void walkAccepted(Uint8List b) { + final c = der.content(b); + if (b[0] & 0x20 == 0) { + expect(() => der.split(b), throwsA(isA())); + if (b[0] == 0x17 || b[0] == 0x18) der.parseTime(b); + return; + } + final s = der.split(b); + expect(s.id, b[0]); + expect(toHex(concatBytes(s.children)), toHex(c), reason: toHex(b)); + for (final child in s.children) { + walkAccepted(child); + } +} + +// Bytes that matter to DER: identifiers, lengths, and the characters of a +// time. +const _boundaries = [ + 0x00, 0x01, 0x02, 0x04, 0x05, 0x06, 0x17, 0x18, 0x1f, 0x30, 0x31, 0x2e, // + 0x5a, 0x7f, 0x80, 0x81, 0x82, 0x83, 0x84, 0xa0, 0xff, +]; + +/// [b] with one to three random edits, as the mutations of a fuzzer. +Uint8List mutateDer(Random r, List b) { + final out = List.of(b); + for (var n = 1 + r.nextInt(3); n > 0; n--) { + final k = r.nextInt(7); + if (k == 0 && out.isNotEmpty) { + out[r.nextInt(out.length)] ^= 1 << r.nextInt(8); + } else if (k == 1 && out.isNotEmpty) { + out[r.nextInt(out.length)] = _boundaries[r.nextInt(_boundaries.length)]; + } else if (k == 2 && out.isNotEmpty) { + out.length = r.nextInt(out.length); + } else if (k == 3) { + out.insert(r.nextInt(out.length + 1), r.nextInt(256)); + } else if (k == 4 && out.length > 1) { + out.removeAt(r.nextInt(out.length)); + } else if (k == 5 && out.isNotEmpty) { + out[r.nextInt(out.length)] = 0x30 + r.nextInt(10); + } else { + out.add(r.nextInt(256)); + } + } + return Uint8List.fromList(out); +} diff --git a/test/der_test.dart b/test/der_test.dart new file mode 100644 index 0000000..8be1527 --- /dev/null +++ b/test/der_test.dart @@ -0,0 +1,655 @@ +// The strict DER of X.690 that spec §29.10 asks of a CMS signature, as +// der_test.go of datekeys-go at 601e6d2 (the draft of v0.12). Every error text +// is the one that the Go reference prints for the same input; the fuzz target +// is a property over seeded mutations of its seeds. + +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:datekeys/datekeys.dart' show fromHex, toHex; +import 'package:datekeys/src/der.dart' as der; +import 'package:test/test.dart'; + +import 'der_support.dart'; + +Uint8List h(String hex) => fromHex(hex); + +String zeros(int n) => '00' * n; + +String hexOf(String s) => toHex(utf8.encode(s)); + +/// The message of the DerException that [f] throws. +String derError(void Function() f) { + try { + f(); + } on der.DerException catch (e) { + return e.message; + } + fail('no DerException'); +} + +void main() { + test('check accepts exactly the DER of the profile', () { + // The cases of TestCheck: null for DER, and the text of the reference + // for the rest. + final cases = <(String, String, String?)>[ + ( + 'sequence of an integer and a null', + '3005020101' + '0500', + null, + ), + ( + 'a long length', + '04' + '8180${zeros(128)}', + null, + ), + ( + 'a long form under 128', + '0481' + '01' + '00', + 'der: a long form for a length under 128', + ), + ( + 'a length with a leading zero', + '04820001' + '00', + 'der: a length with a leading zero', + ), + ( + 'a length of 128 with a leading zero', + '04820080${zeros(128)}', + 'der: a length with a leading zero', + ), + ('an indefinite length', '30800000', 'der: an indefinite length'), + ('a length of 0xff', '04ff${zeros(127)}', 'der: a length of 0xff'), + ( + 'a length of five bytes', + '0485' + '0100000000' + '00', + 'der: a length that does not fit', + ), + ( + 'a length of nine bytes that wraps around to 128', + '0489' + '010000000000000080${zeros(128)}', + 'der: a length that does not fit', + ), + ( + 'a length whose bytes are missing', + '0482' + '01', + 'der: a length that does not fit', + ), + ('a lone identifier octet', '04', 'der: truncated element'), + ('nothing', '', 'der: truncated element'), + ('a high tag number', '1f0100', 'der: a tag number of 31 or more'), + ('truncated', '0402aa', 'der: an element longer than its container'), + ( + 'trailing bytes', + '0500' + '00', + 'der: 1 bytes after the element', + ), + ('BOOLEAN 01', '010101', 'der: a BOOLEAN that is not 00 or FF'), + ('BOOLEAN 00', '010100', null), + ('BOOLEAN FF', '0101ff', null), + ( + 'BOOLEAN of two bytes', + '0102ffff', + 'der: a BOOLEAN that is not 00 or FF', + ), + ( + 'INTEGER with a leading zero', + '02020001', + 'der: an INTEGER that is not minimal', + ), + ('INTEGER 0x80 with its zero', '02020080', null), + ( + 'INTEGER with a leading FF', + '0202ff80', + 'der: an INTEGER that is not minimal', + ), + ( + 'INTEGER -1 in two bytes', + '0202ffff', + 'der: an INTEGER that is not minimal', + ), + ('INTEGER -129', '0202ff7f', null), + ('empty INTEGER', '0200', 'der: an empty INTEGER'), + ( + 'ENUMERATED with a leading zero', + '0a020001', + 'der: an INTEGER that is not minimal', + ), + ('NULL with content', '050100', 'der: a NULL with content'), + ( + 'constructed OCTET STRING', + '2404' + '0402aabb', + 'der: constructed form of the universal type 4', + ), + ( + 'constructed INTEGER', + '2203' + '020101', + 'der: constructed form of the universal type 2', + ), + ( + 'BIT STRING with unused bits set', + '03020701', + 'der: a BIT STRING with unused bits that are not zero', + ), + ('BIT STRING with unused bits clear', '03020780', null), + ('BIT STRING of 4 bits', '030204f0', null), + ('an empty BIT STRING', '030100', null), + ( + 'a BIT STRING without its first octet', + '0300', + 'der: a malformed BIT STRING', + ), + ( + 'a BIT STRING of eight unused bits', + '03020800', + 'der: a malformed BIT STRING', + ), + ( + 'a BIT STRING of no bits with unused bits', + '030101', + 'der: a malformed BIT STRING', + ), + ('OID', '06032a0304', null), + ( + 'OID with 0x80 inside a subidentifier', + '0604' + '2a818001', + null, + ), + ( + 'OID with a leading 0x80', + '06038001' + '02', + 'der: an OBJECT IDENTIFIER with a leading 0x80 in a subidentifier', + ), + ( + 'OID that does not end', + '06022a83', + 'der: a malformed OBJECT IDENTIFIER', + ), + ('an empty OID', '0600', 'der: a malformed OBJECT IDENTIFIER'), + ('context tag, constructed', 'a003020101', null), + ('context tag, primitive, any content', '8003000000', null), + ( + 'SET in primitive form', + '1100', + 'der: the universal type 17 in primitive form', + ), + ( + 'SEQUENCE in primitive form', + '1000', + 'der: the universal type 16 in primitive form', + ), + ( + 'the end of contents', + '0000', + 'der: the universal type 0, which the profile does not use', + ), + ( + 'a reserved universal tag', + '0e0141', + 'der: the universal type 14, which the profile does not use', + ), + ( + 'a SEQUENCE of the end of contents', + '30020000', + 'der: the universal type 0, which the profile does not use', + ), + ( + 'a SEQUENCE with a bad child', + '3003' + '020000', + 'der: an empty INTEGER', + ), + ( + 'a SEQUENCE whose child does not fit', + '3003' + '040500', + 'der: an element longer than its container', + ), + ('UTF8String', '0c026162', null), + ('UTF8String that is not UTF-8', '0c01ff', null), + ('PrintableString with an underscore', '13015f', null), + ('TeletexString', '1401e9', null), + ('IA5String', '160161', null), + ('VisibleString', '1a0161', null), + ('UniversalString', '1c0400000061', null), + ('BMPString', '1e020061', null), + // The other string types are DER too, whatever their content: a name + // may hold a NumericString, as the INN of a Russian certificate. + ('NumericString', '1204${hexOf('1234')}', null), + ('NumericString with a letter', '1201${hexOf('A')}', null), + ('VideotexString', '150141', null), + ('GraphicString', '190141', null), + ('GeneralString', '1b0141', null), + ('ObjectDescriptor', '070141', null), + ( + 'NumericString in constructed form', + '3203' + '120131', + 'der: constructed form of the universal type 18', + ), + ( + 'REAL', + '0900', + 'der: the universal type 9, which the profile does not use', + ), + ( + 'RELATIVE-OID', + '0d0101', + 'der: the universal type 13, which the profile does not use', + ), + // Times in the forms of DER (X.690 11.7, 11.8). + ('UTCTime', '170d${hexOf('250101120000Z')}', null), + ( + 'an empty UTCTime', + '1700', + 'der: a time that is not in the form of DER', + ), + ( + 'UTCTime without seconds', + '170b${hexOf('2501011200Z')}', + 'der: a time that is not in the form of DER', + ), + ( + 'UTCTime with a digit more', + '170e${hexOf('2501011200001Z')}', + 'der: a time that is not in the form of DER', + ), + ( + 'UTCTime with an offset', + '1711${hexOf('250101120000+0100')}', + 'der: a time that is not in the form of DER', + ), + ( + 'UTCTime of 30 February', + '170d${hexOf('250230120000Z')}', + 'der: a date or a time that does not exist', + ), + ( + 'UTCTime with second 60', + '170d${hexOf('250101235960Z')}', + 'der: a date or a time that does not exist', + ), + ( + 'a UTCTime that is not a time', + '170a${hexOf('not a time')}', + 'der: a time that is not in the form of DER', + ), + ( + 'UTCTime with a slash in its seconds', + '170d${hexOf('2501011200/0Z')}', + 'der: a time that is not in the form of DER', + ), + ( + 'UTCTime with a colon in its day', + '170d${hexOf('25010:120000Z')}', + 'der: a time that is not in the form of DER', + ), + ('GeneralizedTime', '180f${hexOf('20250101120000Z')}', null), + ( + 'GeneralizedTime with a fraction', + '1812${hexOf('20250101120000.25Z')}', + null, + ), + ( + 'GeneralizedTime with a trailing zero', + '1813${hexOf('20250101120000.250Z')}', + 'der: a time that is not in the form of DER', + ), + ( + 'GeneralizedTime with an empty fraction', + '1810${hexOf('20250101120000.Z')}', + 'der: a time that is not in the form of DER', + ), + ( + 'GeneralizedTime with a letter in its fraction', + '1812${hexOf('20250101120000.2aZ')}', + 'der: a time that is not in the form of DER', + ), + ( + 'GeneralizedTime without Z', + '180e${hexOf('20250101120000')}', + 'der: a time that is not in the form of DER', + ), + ( + 'GeneralizedTime with a comma', + '1812${hexOf('20250101120000,25Z')}', + 'der: a time that is not in the form of DER', + ), + ( + 'GeneralizedTime without seconds', + '180d${hexOf('202501011200Z')}', + 'der: a time that is not in the form of DER', + ), + ( + 'GeneralizedTime with a slash in its seconds', + '180f${hexOf('202501011200/0Z')}', + 'der: a time that is not in the form of DER', + ), + ( + 'GeneralizedTime of month 0', + '180f${hexOf('20250001120000Z')}', + 'der: a date or a time that does not exist', + ), + ( + 'GeneralizedTime of month 13', + '180f${hexOf('20251301120000Z')}', + 'der: a date or a time that does not exist', + ), + ( + 'GeneralizedTime of day 0', + '180f${hexOf('20250100120000Z')}', + 'der: a date or a time that does not exist', + ), + ( + 'GeneralizedTime of 31 April', + '180f${hexOf('20250431120000Z')}', + 'der: a date or a time that does not exist', + ), + ( + 'GeneralizedTime of hour 24', + '180f${hexOf('20250101240000Z')}', + 'der: a date or a time that does not exist', + ), + ( + 'GeneralizedTime of minute 60', + '180f${hexOf('20250101126000Z')}', + 'der: a date or a time that does not exist', + ), + ( + 'GeneralizedTime of 29 February 2024', + '180f${hexOf('20240229235959Z')}', + null, + ), + // What a check that is missing would let through: the last byte read + // as Z, a colon read as the digit 10, a slash read as a year, a tag of + // a high number read as one byte, and an indefinite length read as a + // long form. + ( + 'UTCTime that ends in another letter', + '170d${hexOf('250101120000X')}', + 'der: a time that is not in the form of DER', + ), + ( + 'GeneralizedTime with a digit in the place of Z', + '180f${hexOf('202501011200000')}', + 'der: a time that is not in the form of DER', + ), + ( + 'GeneralizedTime with a colon in its day', + '180f${hexOf('2025010:120000Z')}', + 'der: a time that is not in the form of DER', + ), + ( + 'GeneralizedTime with a slash in its year', + '180f${hexOf('/0250101120000Z')}', + 'der: a time that is not in the form of DER', + ), + ( + 'a context tag of a high number', + '9f0100', + 'der: a tag number of 31 or more', + ), + ( + 'an indefinite length and nothing after it', + '3080', + 'der: an indefinite length', + ), + ]; + for (final (name, hex, text) in cases) { + final b = h(hex); + if (text == null) { + expect(() => der.check(b), returnsNormally, reason: name); + } else { + expect(derError(() => der.check(b)), text, reason: name); + } + } + }); + + test( + 'the elements of a SET OF go in ascending order, equal ones may repeat', + () { + final a = [0x02, 0x01, 0x01]; + final b = [0x02, 0x01, 0x02]; + expect(der.setOfSorted([a, b]), isTrue); + expect(der.setOfSorted([b, a]), isFalse); + expect(der.setOfSorted([a, a, b]), isTrue); + expect(der.setOfSorted([a, b, a]), isFalse); + expect(der.setOfSorted([]), isTrue); + }, + ); + + test('elements nested 32 levels below the outer one are DER, 33 are not', () { + Uint8List nested(int n) { + var b = [0x05, 0x00]; + for (var i = 0; i < n; i++) { + b = [0x30, b.length, ...b]; + } + return Uint8List.fromList(b); + } + + der.check(nested(32)); + expect(derError(() => der.check(nested(33))), 'der: nested too deep'); + }); + + test('parseTime reads the times of DER exactly to the nanosecond', () { + // Seconds since the epoch, nanoseconds and the form of RFC 3339, as Go's + // Time.Unix, Time.Nanosecond and RFC3339Nano print them. + final cases = <(String, int, int, bool, String)>[ + ( + '\x17\x0d' + '491231235959Z', + 2524607999, + 0, + false, + '2049-12-31T23:59:59Z', + ), + ( + '\x17\x0d' + '500101000000Z', + -631152000, + 0, + false, + '1950-01-01T00:00:00Z', + ), + ( + '\x18\x13' + '20240229120000.125Z', + 1709208000, + 125000000, + true, + '2024-02-29T12:00:00.125Z', + ), + ( + '\x18\x19' + '20240229120000.123456789Z', + 1709208000, + 123456789, + true, + '2024-02-29T12:00:00.123456789Z', + ), + ( + '\x18\x0f' + '19490101000000Z', + -662688000, + 0, + false, + '1949-01-01T00:00:00Z', + ), + // Digits beyond the nanosecond are dropped, as Go drops them. + ( + '\x18\x1b' + '20240229120000.12345678912Z', + 1709208000, + 123456789, + true, + '2024-02-29T12:00:00.123456789Z', + ), + // The proleptic Gregorian calendar of Go: the year 0 is a leap year. + ( + '\x18\x0f' + '00000229120000Z', + -62162078400, + 0, + false, + '0000-02-29T12:00:00Z', + ), + ( + '\x18\x0f' + '99991231235959Z', + 253402300799, + 0, + false, + '9999-12-31T23:59:59Z', + ), + ]; + for (final (el, seconds, nanos, fraction, text) in cases) { + final t = der.parseTime(Uint8List.fromList(el.codeUnits)); + expect(t.time.unixSeconds, seconds, reason: text); + expect(t.time.nanosecond, nanos, reason: text); + expect(t.fraction, fraction, reason: text); + expect('${t.time}', text); + } + final bad = <(String, String)>[ + ( + '\x18\x0f' + '20230229120000Z', + 'der: a date or a time that does not exist', + ), + ( + '\x04\x0d' + '491231235959Z', + 'der: not a UTCTime or a GeneralizedTime', + ), + // A GeneralizedTime in an OCTET STRING, a UTCTime in a UTF8String. + ( + '\x04\x0f' + '20230228120000Z', + 'der: not a UTCTime or a GeneralizedTime', + ), + ( + '\x0c\x0d' + '491231235959Z', + 'der: not a UTCTime or a GeneralizedTime', + ), + ('\x17', 'der: not a UTCTime or a GeneralizedTime'), + // Its content does not fit. + ( + '\x17\x0d' + '4912', + 'der: an element longer than its container', + ), + ('', 'der: not a UTCTime or a GeneralizedTime'), + ]; + for (final (el, text) in bad) { + expect( + derError(() => der.parseTime(Uint8List.fromList(el.codeUnits))), + text, + ); + } + }); + + test('times compare in order, to the nanosecond', () { + der.DerTime t(String s) => der + .parseTime(Uint8List.fromList([0x18, s.length, ...s.codeUnits])) + .time; + final a = t('20240229120000.1Z'); + final b = t('20240229120000.100000001Z'); + final c = t('20240229120001Z'); + expect(a.isBefore(b), isTrue); + expect(b.isBefore(c), isTrue); + expect(c.isAfter(a), isTrue); + expect(a.compareTo(a), 0); + expect(a, t('20240229120000.1Z')); + expect(a, isNot(b)); + // A UTCTime and a GeneralizedTime of the same instant are the same time. + final utc = der.parseTime(h('170d${hexOf('491231235959Z')}')).time; + expect(utc, t('20491231235959Z')); + expect(utc.hashCode, t('20491231235959Z').hashCode); + }); + + test('split and content read the children and the content', () { + final b = h('30050201010500'); + der.check(b); + final s = der.split(b); + expect(s.id, 0x30); + expect(s.children.map(toHex), ['020101', '0500']); + expect(toHex(der.content(b)), '0201010500'); + // Nothing, a primitive element, and constructed ones whose content, or a + // child, does not fit. + final bad = <(String, String)>[ + ('', 'der: not a constructed element'), + ('0400', 'der: not a constructed element'), + ('3005', 'der: an element longer than its container'), + ('a005', 'der: an element longer than its container'), + ('300304050000', 'der: an element longer than its container'), + ]; + for (final (hex, text) in bad) { + expect(derError(() => der.split(h(hex))), text, reason: hex); + } + expect( + derError(() => der.content(h('040500'))), + 'der: an element longer than its container', + ); + expect(derError(() => der.content(h('04'))), 'der: truncated element'); + }); + + test('a child that does not fit is an error of split, which returns', () { + // The loop over the children advances by each header: a child that does + // not fit must end it, or it would never end. + expect( + derError(() => der.split(h('3003040500'))), + 'der: an element longer than its container', + ); + }); + + test('what check accepts, split, content and parseTime read', () { + // FuzzDERCheck over seeded mutations of its seeds: the children of a + // constructed element are its content exactly, a primitive one is not + // split, and a time of the universal class is read by parseTime. + final seeds = [ + for (final s in [ + '30050201010500', + 'a003020101', + '0603' + '2a0304', + '170d${hexOf('250101120000Z')}', + '1812${hexOf('20250101120000.25Z')}', + '30800000', + '0489' + '010000000000000080', + '3010' + '170d${hexOf('491231235959Z')}' + '0101ff', + ]) + h(s), + ]; + final r = Random(7); + var accepted = 0; + for (var i = 0; i < 20000; i++) { + final b = mutateDer(r, seeds[r.nextInt(seeds.length)]); + try { + der.check(b); + } on der.DerException { + continue; + } + accepted++; + walkAccepted(b); + } + expect(accepted, greaterThan(100)); + }); +} diff --git a/test/der_vectors_test.dart b/test/der_vectors_test.dart new file mode 100644 index 0000000..e74f533 --- /dev/null +++ b/test/der_vectors_test.dart @@ -0,0 +1,74 @@ +// The DER elements of the security areas of security_cms.json, the +// signatures and the tokens, as the seeds of FuzzDERCheck of datekeys-go: +// check accepts each, split, content and parseTime read it element by +// element, and so for every mutation of them that check accepts. +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:datekeys/datekeys.dart' show fromHex; +import 'package:datekeys/src/der.dart' as der; +import 'package:test/test.dart'; + +import 'der_support.dart'; + +/// The outermost runs of bytes of [b] that are one DER SEQUENCE of more than +/// 127 bytes each, as derElements of der_test.go. +List derElements(Uint8List b) { + final out = []; + for (var i = 0; i + 2 < b.length; i++) { + if (b[i] != 0x30 || b[i + 1] < 0x81 || b[i + 1] > 0x83) continue; + final rest = Uint8List.sublistView(b, i); + final Uint8List element; + try { + final content = der.content(rest); + final headerLength = content.offsetInBytes - rest.offsetInBytes; + element = Uint8List.sublistView(b, i, i + headerLength + content.length); + der.check(element); + } on der.DerException { + continue; + } + out.add(element); + i += element.length - 1; + } + return out; +} + +void main() { + final file = jsonDecode( + File('testdata/vectors/security_cms.json').readAsStringSync(), + ) as Map; + final areas = [ + for (final c in (file['cases']! as List).cast>()) + fromHex(c['security_cbor']! as String), + ]; + final seeds = [for (final a in areas) ...derElements(a)]; + + test('the signatures and tokens of security_cms.json are DER', () { + expect(areas, isNotEmpty); + // Some cases hold no DER on purpose: a signature that is not a CMS, a + // seal that is not DER, SIGNERS out of order or empty. + expect(seeds, isNotEmpty); + seeds.forEach(walkAccepted); + }); + + test('what check accepts of their mutations, the readers read', () { + final r = Random(8); + var accepted = 0; + for (var i = 0; i < 3000; i++) { + final b = mutateDer(r, seeds[r.nextInt(seeds.length)]); + try { + der.check(b); + } on der.DerException { + continue; + } + accepted++; + walkAccepted(b); + } + expect(accepted, greaterThan(0)); + }); +}