diff --git a/.gitignore b/.gitignore index 83003a99..c518e69a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ metrics/ /pyasn1/*/*/__pycache__/ /core/src/jvmTest/pyasn1/ repo +/core/collected.txt \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 11edb140..610f3a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ * `Asn1Integer` negative INTEGER decode/encode no longer detours through quadratic decimal-string round-trips; two's-complement conversion now stays in byte arithmetic. * Large ASN.1 varint / OID arc decoding no longer grows work quadratically through repeated `shl`/`or` chains; big unsigned varints are now unpacked in one pass. * `BitSet(nBits)` now rejects the exact preallocation overflow boundary instead of wrapping during the final `+ 1` byte-count adjustment. +* **Features:** + * ASN.1 GENERALIZED TIME now supports arbitrary precision fractional second representation. **This is a breaking change** + * `Asn1Time` is now a `sealed` class consisting of + * `SecondsCapped`, trimming fractional seconds (old behaviour) + * `Fractional`, keeping arbitrary precision fractional seconds (full DER-compliance) + * `X509TbsCertificate` now takes `SecondsCapped` time as constructor parameters, but still parses `Fractional` time for leniency. + * `ObjectIdentifier` is now `Comparable` * **Other Changes:** * Add a `benchmarks` module with certificate, length, raw-TLV, rendering, resource-corpus, and SET-sorting benchmarks. * Extend public docs for low-level parsing and `kxs` behavior, including newer hardening and limit semantics. diff --git a/core/src/commonMain/kotlin/at/asitplus/awesn1/Asn1Time.kt b/core/src/commonMain/kotlin/at/asitplus/awesn1/Asn1Time.kt index 873bb4c6..0e03bf82 100644 --- a/core/src/commonMain/kotlin/at/asitplus/awesn1/Asn1Time.kt +++ b/core/src/commonMain/kotlin/at/asitplus/awesn1/Asn1Time.kt @@ -5,11 +5,9 @@ package at.asitplus.awesn1 -import at.asitplus.awesn1.encoding.encodeToAsn1GeneralizedTimePrimitive -import at.asitplus.awesn1.encoding.encodeToAsn1UtcTimePrimitive -import at.asitplus.awesn1.encoding.decodeGeneralizedTimeFromAsn1ContentBytes -import at.asitplus.awesn1.encoding.decodeToInstant -import at.asitplus.awesn1.encoding.decodeUtcTimeFromAsn1ContentBytes +import at.asitplus.awesn1.Asn1Time.Companion.invoke +import at.asitplus.awesn1.Asn1Time.Fractional.Companion.FRACTIONAL_SECONDS +import at.asitplus.awesn1.encoding.* import at.asitplus.awesn1.serialization.Asn1Serializer import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable @@ -21,83 +19,134 @@ import kotlinx.serialization.encoding.Encoder import kotlin.time.Instant /** - * ASN.1 TIME (required since GENERALIZED TIME and UTC TIME exist) + * ASN.1 TIME (required since GENERALIZED TIME and UTC TIME exist). * - * @param instant the timestamp to encode - * @param formatOverride to force either GENERALIZED TIME or UTC TIME + * The concrete subtype — [SecondsCapped] vs [Fractional] — is the **single source of truth** for whether an + * encoded fractional second is present: + * - [SecondsCapped]: no fractional second (UTC TIME, or GENERALIZED TIME with no `.` fraction). + * - [Fractional]: an explicitly encoded fractional second, held verbatim in [Fractional.fractionalSeconds] + * (may even be `"0"`; see [Fractional.fractionalSeconds]). + * + * Do **not** infer the presence or absence of a fraction from [instant] (or `instant.nanosecondsOfSecond`). + * [instant] is truncated to nanosecond resolution, so a [Fractional] carrying a sub-nanosecond or all-zero + * fraction (e.g. `.0000000000001` or `.000`) can report `nanosecondsOfSecond == 0` while still encoding a + * fraction. Branch on the subtype, never on the instant — using the instant may misclassify cases. */ @Serializable(with = Asn1Time.Companion::class) -class Asn1Time(instant: Instant, formatOverride: Format? = null) : Asn1Encodable { - - val instant = Instant.fromEpochSeconds(instant.epochSeconds) +sealed class Asn1Time : Asn1Encodable { /** - * Indicates whether this timestamp uses UTC TIME or GENERALIZED TIME + * The timestamp **value only**, truncated to [Instant]'s nanosecond resolution. For [SecondsCapped] this + * is whole-second; for [Fractional] it reflects the decoded fraction only up to nanoseconds. + * + * This is lossy with respect to the encoding: the exact, arbitrary-precision fraction (which may exceed + * nanoseconds, or be all zeroes) lives in [Fractional.fractionalSeconds], and whether a fraction is encoded + * at all is determined by the subtype. Never use [instant] or `instant.nanosecondsOfSecond` to decide + * whole-second vs fractional — see the class-level note. */ - val format: Format = - formatOverride ?: if (this.instant !in THRESHOLD_UTC_TIME..( - leadingTags = setOf(Asn1Element.Tag.TIME_UTC, Asn1Element.Tag.TIME_GENERALIZED), - decodable = object : Asn1Decodable { - @Throws(Asn1Exception::class) - override fun doDecode(src: Asn1Primitive) = - Asn1Time(src.decodeToInstant(), if (src.tag == Asn1Element.Tag.TIME_UTC) Format.UTC else Format.GENERALIZED) - }, - fallbackSerializer = Asn1TimeSerializer, - ) { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor(ASN1_DESCRIPTOR_TIME, PrimitiveKind.STRING) + /** Indicates whether this timestamp uses UTC TIME or GENERALIZED TIME. */ + abstract val format: Format - @Throws(Asn1Exception::class) - override fun decodeFromTlv(src: Asn1Primitive, assertTag: Asn1Element.Tag?): Asn1Time { - verifyTag(src, assertTag) - val effectiveTag = assertTag ?: src.tag - return when (effectiveTag) { - Asn1Element.Tag.TIME_UTC -> - Asn1Time(Instant.decodeUtcTimeFromAsn1ContentBytes(src.content), Format.UTC) + /** + * An [Asn1Time] with **no encoded fractional second** (whole-second) — the canonical DER-minimal form, and + * the only way to construct a time from Kotlin. + * + * A value being whole-second is *equivalent to* being a [SecondsCapped]. The converse is **not** true for + * [Fractional]: a [Fractional] whose value happens to land on a whole second (e.g. an all-zero fraction + * `.000`) is still a [Fractional], because it encodes differently. Detect "no fraction" via `is SecondsCapped`, + * never via [instant]. + * + * @param instant the timestamp to encode; any sub-second part is dropped + * @param formatOverride force either GENERALIZED TIME or UTC TIME + */ + class SecondsCapped(instant: Instant, formatOverride: Format? = null) : Asn1Time() { + override val instant: Instant = Instant.fromEpochSeconds(instant.epochSeconds) + override val format: Format = formatOverride ?: pickFormat(this.instant) + } - Asn1Element.Tag.TIME_GENERALIZED -> - Asn1Time(Instant.decodeGeneralizedTimeFromAsn1ContentBytes(src.content), Format.GENERALIZED) - else -> { - catchingUnwrapped { Instant.decodeUtcTimeFromAsn1ContentBytes(src.content) } - .getOrNull() - ?.let { return Asn1Time(it, Format.UTC) } + /** + * Returns a fresh instance of a [SecondsCapped] version of this [Asn1Time]. + */ + fun secondsCapped(): SecondsCapped = SecondsCapped(instant) - catchingUnwrapped { Instant.decodeGeneralizedTimeFromAsn1ContentBytes(src.content) } - .getOrNull() - ?.let { return Asn1Time(it, Format.GENERALIZED) } + /** + * A GENERALIZED TIME carrying an exact fractional second. Produced **only** by decoding or from a + * sub-second [Instant]; a whole-second value is always a [SecondsCapped] instead. + */ + class Fractional internal constructor( + override val instant: Instant, + /** + * Fractional-second digits. + * Matches [FRACTIONAL_SECONDS] regex: one or more digits. Every digit is significant and preserved, + * including leading and trailing zeros and an all-zero fraction: `"05"` (0.05 s) ≠ `"5"` (0.5 s), + * `"120"` is kept verbatim rather than normalized to `"12"`, and `"000"` is kept rather than dropped to ensure + * even faulty encodings are round-tripped. + * Although cursed, certificates with such time encodings exist in practice. + * + * When derived from an [Instant], trailing zeros are stripped (DER minimum encoding). + * May carry more precision than [instant]'s nanosecond resolution. + */ + val fractionalSeconds: String, + ) : Asn1Time() { - throw Asn1StructuralException("Unsupported ASN.1 time tag $effectiveTag") - } + init { + require(FRACTIONAL_SECONDS.matches(fractionalSeconds)) { + "fractionalSeconds must match /${FRACTIONAL_SECONDS.pattern}/ (one or more digits): '$fractionalSeconds'" } } - private val THRESHOLD_UTC_TIME = Instant.parse("1950-01-01T00:00:00Z") - private val THRESHOLD_GENERALIZED_TIME = Instant.parse("2050-01-01T00:00:00Z") + /** Derives the canonical fraction from a sub-second [Instant]: 9-digit nanoseconds, trailing zeros stripped. */ + internal constructor(instant: Instant) : this( + instant, + instant.nanosecondsOfSecond.toString().padStart(9, '0').trimEnd('0') + ) + + override val format: Format get() = Format.GENERALIZED + + override fun hashCode(): Int = super.hashCode() * 31 + fractionalSeconds.hashCode() + + override fun equals(other: Any?): Boolean = + super.equals(other) && other is Fractional && fractionalSeconds == other.fractionalSeconds + + override fun toString(): String = "Asn1Time(instant=$instant, format=$format, fraction=.$fractionalSeconds)" + + companion object { + /** + * Fractional-second digits: one or more digits; every digit + * (incl. leading/trailing/all zeros) is significant. + */ + val FRACTIONAL_SECONDS = Regex("[0-9]+") + } } override fun encodeToTlv(): Asn1Primitive = - when (format) { - Format.UTC -> instant.encodeToAsn1UtcTimePrimitive() - Format.GENERALIZED -> instant.encodeToAsn1GeneralizedTimePrimitive() + when (this) { + is Fractional -> { + val fraction = fractionalSeconds + val whole = instant.encodeToAsn1Time().dropLast(1) // strip trailing 'Z' -> "YYYYMMDDHHMMSS" + val body = if (fraction.isEmpty()) whole else "$whole.${fraction}" + Asn1Primitive(Asn1Element.Tag.TIME_GENERALIZED, "${body}Z".encodeToByteArray()) + } + + is SecondsCapped -> when (format) { + Format.UTC -> instant.encodeToAsn1UtcTimePrimitive() + Format.GENERALIZED -> instant.encodeToAsn1GeneralizedTimePrimitive() + } } + override fun equals(other: Any?): Boolean { if (this === other) return true - if (other == null || this::class != other::class) return false - - other as Asn1Time + if (other !is Asn1Time) return false + // A SecondsCapped and a Fractional never encode to the same bytes (the latter carries an explicit + // fraction, even an all-zero one), so they must not compare equal even when their instants coincide. + if ((this is Fractional) != (other is Fractional)) return false + return instant == other.instant && + format == other.format - if (instant != other.instant) return false - if (format != other.format) return false - - return true } override fun hashCode(): Int { @@ -106,33 +155,82 @@ class Asn1Time(instant: Instant, formatOverride: Format? = null) : Asn1Encodable return result } - override fun toString(): String { - return "Asn1Time(instant=$instant, format=$format)" - } + override fun toString(): String = "Asn1Time(instant=$instant, format=$format)" + + companion object : Asn1Serializer( + leadingTags = setOf(Asn1Element.Tag.TIME_UTC, Asn1Element.Tag.TIME_GENERALIZED), + decodable = object : Asn1Decodable { + @Throws(Asn1Exception::class) + override fun doDecode(src: Asn1Primitive): Asn1Time = + if (src.tag == Asn1Element.Tag.TIME_UTC) fromUtc(src.content) + else decodeGeneralizedTimeToAsn1Time(src.content) + }, + fallbackSerializer = Asn1TimeSerializer, + ) { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor(ASN1_DESCRIPTOR_TIME, PrimitiveKind.STRING) + + /** Constructs a whole-second [Asn1Time] from an [Instant]. Sub-second precision is dropped (see [SecondsCapped]). */ + operator fun invoke(instant: Instant, formatOverride: Format? = null): Asn1Time { + return if (instant.nanosecondsOfSecond == 0) SecondsCapped(instant, formatOverride) + else if (formatOverride == Format.UTC) throw IllegalArgumentException("Cannot construct fractional UTC time") + else Fractional(instant) + } - /** - * Enum of supported Time formats - */ - enum class Format { /** - * UTC TIME + * Parses an ASN.1 GENERALIZED TIME value string (`YYYYMMDDHHMMSS[.fraction]Z`) into an [Asn1Time]. + * Unlike [invoke] from an [Instant] — which is bounded by nanosecond resolution — this preserves an + * **arbitrary-precision** fractional second. Reuses the low-level GENERALIZED TIME string parser. */ + @Throws(Asn1Exception::class) + operator fun invoke(generalizedTime: String): Asn1Time = + decodeGeneralizedTimeToAsn1Time(generalizedTime.encodeToByteArray()) + + @Throws(Asn1Exception::class) + override fun decodeFromTlv(src: Asn1Primitive, assertTag: Asn1Element.Tag?): Asn1Time { + verifyTag(src, assertTag) + return when (assertTag ?: src.tag) { + Asn1Element.Tag.TIME_UTC -> fromUtc(src.content) + Asn1Element.Tag.TIME_GENERALIZED -> decodeGeneralizedTimeToAsn1Time(src.content) + else -> catchingUnwrapped { fromUtc(src.content) }.getOrNull() + ?: catchingUnwrapped { decodeGeneralizedTimeToAsn1Time(src.content) }.getOrNull() + ?: throw Asn1StructuralException("Unsupported ASN.1 time tag ${assertTag ?: src.tag}") + } + } + + } + + /** Enum of supported Time formats */ + enum class Format { + /** UTC TIME */ UTC, - /** - * GENERALIZED TIME - */ + /** GENERALIZED TIME */ GENERALIZED } } + +private val THRESHOLD_UTC_TIME = Instant.parse("1950-01-01T00:00:00Z") +private val THRESHOLD_GENERALIZED_TIME = Instant.parse("2050-01-01T00:00:00Z") + + +/** RFC 5280 §4.1.2.5 cut-over: times in `[1950,2050)` use UTC TIME, everything else GENERALIZED TIME. */ +private fun pickFormat(instant: Instant): Asn1Time.Format = + if (instant !in THRESHOLD_UTC_TIME.. { override val descriptor: SerialDescriptor = @@ -146,3 +244,8 @@ internal object Asn1TimeSerializer : KSerializer { return Asn1Time(Instant.parse(decoder.decodeString())) } } + +/** + * Returns a [Instant] with the same epoch seconds, but nanosecond precision capped + */ +fun Instant.secondsCapped() = Instant.fromEpochSeconds(this.epochSeconds) \ No newline at end of file diff --git a/core/src/commonMain/kotlin/at/asitplus/awesn1/ObjectIdentifier.kt b/core/src/commonMain/kotlin/at/asitplus/awesn1/ObjectIdentifier.kt index e5e17d95..59159e4d 100644 --- a/core/src/commonMain/kotlin/at/asitplus/awesn1/ObjectIdentifier.kt +++ b/core/src/commonMain/kotlin/at/asitplus/awesn1/ObjectIdentifier.kt @@ -33,8 +33,7 @@ import kotlin.uuid.Uuid class ObjectIdentifier @Throws(Asn1Exception::class) private constructor( bytes: ByteArray?, nodes: List? -) : - Asn1Encodable { +) : Asn1Encodable, Comparable { init { if ((bytes == null) && (nodes == null)) { //we're not even declaring this, since this is an implementation error on our end @@ -170,6 +169,22 @@ class ObjectIdentifier @Throws(Asn1Exception::class) private constructor( return bytes.contentHashCode() } + /** + * Orders OIDs by their DER encoding ([bytes]) using unsigned lexicographic byte comparison — i.e. the + * canonical "sorted by encoding" order (e.g. RFC 4514 §2.3 multi-valued RDN ordering). Consistent with + * [equals]: `compareTo(other) == 0` iff `equals(other)`. + */ + override fun compareTo(other: ObjectIdentifier): Int { + val a = bytes + val b = other.bytes + val n = minOf(a.size, b.size) + for (i in 0 until n) { + val c = (a[i].toInt() and 0xff) - (b[i].toInt() and 0xff) + if (c != 0) return c + } + return a.size - b.size + } + /** * @return an OBJECT IDENTIFIER [Asn1Primitive] */ diff --git a/core/src/commonMain/kotlin/at/asitplus/awesn1/encoding/Asn1Decoding.kt b/core/src/commonMain/kotlin/at/asitplus/awesn1/encoding/Asn1Decoding.kt index 1a09c482..5e32ca55 100644 --- a/core/src/commonMain/kotlin/at/asitplus/awesn1/encoding/Asn1Decoding.kt +++ b/core/src/commonMain/kotlin/at/asitplus/awesn1/encoding/Asn1Decoding.kt @@ -6,7 +6,6 @@ package at.asitplus.awesn1.encoding - import at.asitplus.awesn1.* import at.asitplus.awesn1.BERTags.BMP_STRING import at.asitplus.awesn1.BERTags.IA5_STRING @@ -607,19 +606,40 @@ fun Instant.Companion.decodeUtcTimeFromAsn1ContentBytes(input: ByteArray): Insta * @throws Asn1Exception if the input does not parse */ @Throws(Asn1Exception::class) -fun Instant.Companion.decodeGeneralizedTimeFromAsn1ContentBytes(bytes: ByteArray): Instant = runRethrowing { - val s = bytes.decodeToString() - if (s.length != 15) throw IllegalArgumentException("Input too short: $bytes") - val isoString = "${s[0]}${s[1]}${s[2]}${s[3]}" + // year - "-${s[4]}${s[5]}" + // month - "-${s[6]}${s[7]}" + // day - "T${s[8]}${s[9]}" + // hour - ":${s[10]}${s[11]}" + // minute - ":${s[12]}${s[13]}" + // seconds - "${s[14]}" // time offset - return parse(isoString) +fun Instant.Companion.decodeGeneralizedTimeFromAsn1ContentBytes(bytes: ByteArray): Instant = + // The full-precision ground truth is Asn1Time; this just caps to the Instant's nanosecond resolution. + decodeGeneralizedTimeToAsn1Time(bytes).instant + +/** + * DER (X.690 §11.7): mandatory `YYYYMMDDHHMMSS`, `.` decimal separator, `Z` terminator. + */ +internal fun decodeGeneralizedTimeToAsn1Time(content: ByteArray): Asn1Time = runRethrowing { + val s = content.decodeToString() + require(s.length >= 15) { "GENERALIZED TIME too short: $s" } + require(s.endsWith("Z")) { "GENERALIZED TIME must end with 'Z': $s" } + val base = "${s[0]}${s[1]}${s[2]}${s[3]}" + // year + "-${s[4]}${s[5]}" + // month + "-${s[6]}${s[7]}" + // day + "T${s[8]}${s[9]}" + // hour + ":${s[10]}${s[11]}" + // minute + ":${s[12]}${s[13]}" // seconds + val sep = s.indexOf('.') + if (sep < 0) { + require(s.length == 15) { "Unexpected trailing data in GENERALIZED TIME: $s" } + Asn1Time.SecondsCapped(Instant.parse("${base}Z"), Asn1Time.Format.GENERALIZED) + } else { + require(sep == 14) { "Fractional separator must immediately follow the seconds field: $s" } + val digits = s.substring(sep + 1, s.length - 1) // between the period and trailing 'Z' + require(digits.isNotEmpty() && digits.all { it.isDigit() }) { "Malformed fractional seconds: $s" } + // Lenient: keep the fractional digits verbatim — including leading, trailing, and even all-zero + // fractions — so re-encoding reproduces non-minimal-but-valid DER input byte-for-byte. This matters + // for signature verification: a certificate signed over e.g. "...02.000Z" must round-trip unchanged, + // otherwise the recomputed TBS bytes would not match the signature. (Encoding *from an Instant* still + // strips trailing zeros for DER minimum encoding; see Asn1Time.Fractional.) + // Instant resolves to nanoseconds; the exact arbitrary-precision fraction is kept verbatim in fractionalSeconds. + Asn1Time.Fractional(Instant.parse("$base.${digits.take(9)}Z"), digits) + } } - /** * Decodes a signed [Int] from [bytes] assuming the same encoding as the [Asn1Primitive.content] property of an [Asn1Primitive] containing an ASN.1 INTEGER * diff --git a/core/src/commonMain/kotlin/at/asitplus/awesn1/encoding/Asn1Encoding.kt b/core/src/commonMain/kotlin/at/asitplus/awesn1/encoding/Asn1Encoding.kt index c2b0e749..5ecd07f9 100644 --- a/core/src/commonMain/kotlin/at/asitplus/awesn1/encoding/Asn1Encoding.kt +++ b/core/src/commonMain/kotlin/at/asitplus/awesn1/encoding/Asn1Encoding.kt @@ -448,7 +448,7 @@ fun Instant.encodeToAsn1UtcTimePrimitive() = fun Instant.encodeToAsn1GeneralizedTimePrimitive() = Asn1Primitive(Asn1Element.Tag.TIME_GENERALIZED, encodeToAsn1Time().encodeToByteArray()) -private fun Instant.encodeToAsn1Time(): String { +internal fun Instant.encodeToAsn1Time(): String { val value = this.toString() if (value.isEmpty()) throw IllegalArgumentException("Instant serialization failed: no value") diff --git a/core/src/jvmTest/kotlin/at/asitplus/awesn1/Asn1TimeTest.kt b/core/src/jvmTest/kotlin/at/asitplus/awesn1/Asn1TimeTest.kt index 400b8406..3b2f962c 100644 --- a/core/src/jvmTest/kotlin/at/asitplus/awesn1/Asn1TimeTest.kt +++ b/core/src/jvmTest/kotlin/at/asitplus/awesn1/Asn1TimeTest.kt @@ -1,12 +1,21 @@ package at.asitplus.awesn1 +import at.asitplus.awesn1.encoding.decodeGeneralizedTimeFromAsn1ContentBytes +import at.asitplus.awesn1.encoding.decodeToInstant +import at.asitplus.awesn1.encoding.decodeUtcTimeFromAsn1ContentBytes +import at.asitplus.awesn1.encoding.encodeToAsn1GeneralizedTimePrimitive +import at.asitplus.awesn1.encoding.encodeToAsn1UtcTimePrimitive +import at.asitplus.awesn1.encoding.parse import at.asitplus.testballoon.matrix.matrixSuite +import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf import io.kotest.property.Arb import io.kotest.property.arbitrary.javaInstant import java.time.Instant import kotlin.time.toKotlinInstant +import kotlin.time.Instant as KotlinInstant val Asn1TimeTest by matrixSuite { @@ -23,7 +32,7 @@ val Asn1TimeTest by matrixSuite { val asn1Time = Asn1Time(now.toKotlinInstant()) val asn1Time1 = Asn1Time(then.toKotlinInstant()) - val asn1Time2 = Asn1Time(then.toKotlinInstant(), Asn1Time.Format.UTC) + val asn1Time2 = Asn1Time(then.toKotlinInstant().secondsCapped(), Asn1Time.Format.UTC) val asn1Time3 = Asn1Time(later.toKotlinInstant(), Asn1Time.Format.GENERALIZED) asn1Time shouldBe asn1Time @@ -54,3 +63,138 @@ val Asn1TimeTest by matrixSuite { } } } + + /** Wraps [body] (ASCII) as a DER GENERALIZED TIME primitive (tag 0x18, single-byte length). */ + private fun time(body: String): ByteArray { + val b = body.encodeToByteArray() + require(b.size < 128) + return byteArrayOf(0x18, b.size.toByte()) + b + } + + private fun decode(der: ByteArray) = Asn1Time.decodeFromTlv(Asn1Element.parse(der).asPrimitive()) + + /** + * Builds an [Asn1Time] from the canonical GENERALIZED TIME value [body] (e.g. `"20240102030405.05Z"`) + * through every construction path and asserts they are all equal and round-trip to the same DER bytes: + * - DER: [Asn1Time.decodeFromTlv] of the encoded primitive + * - String: the `Asn1Time(String)` faux-constructor (arbitrary fractional precision) + * - Instant: `Asn1Time(kotlin.time.Instant)` — only when the fraction fits nanosecond precision ([iso] non-null) + * + * Expected subtype/fraction are derived from [body] itself. Pass [iso] = `null` for fractions beyond + * nanosecond precision, where the Instant path cannot represent the value. + */ + private fun assertAllPaths(body: String, iso: String?) { + val der = time(body) + val expectedFraction = body.substringAfter('.', "").removeSuffix("Z").ifEmpty { null } + + val viaDer = decode(der) + val viaString = Asn1Time(body) + val viaInstant = iso?.let { Asn1Time(kotlin.time.Instant.parse(it)) } + + for (t in listOfNotNull(viaDer, viaString, viaInstant)) { + t shouldBe viaDer + t.hashCode() shouldBe viaDer.hashCode() + t.encodeToTlv().derEncoded shouldBe der + if (expectedFraction == null) t.shouldBeInstanceOf() + else t.shouldBeInstanceOf().fractionalSeconds shouldBe expectedFraction + } + } + + val Asn1TimeFocusedTest by matrixSuite { + + // Each case is constructed via DER + String + (when fraction <= nanosecond) Kotlin Instant, and the + // results are asserted equal and byte-identical on re-encode. + + "whole-second GENERALIZED (>= 2050 so format matches across paths)" { + assertAllPaths("20520101000000Z", iso = "2052-01-01T00:00:00Z") + } + + "fraction .5" { + assertAllPaths("20240102030405.5Z", iso = "2024-01-02T03:04:05.5Z") + } + + "fraction .05 (leading zero preserved)" { + assertAllPaths("20240102030405.05Z", iso = "2024-01-02T03:04:05.05Z") + } + + "fraction .123456789 (max nanosecond precision)" { + assertAllPaths("20240102030405.123456789Z", iso = "2024-01-02T03:04:05.123456789Z") + } + + "fraction .1234567890123 (beyond nanosecond; DER + String only)" { + assertAllPaths("20240102030405.1234567890123Z", iso = null) + } + + "fraction .0123456789012 (beyond nanosecond, leading zero; DER + String only)" { + assertAllPaths("20240102030405.0123456789012Z", iso = null) + } + + // ---- trailing zeros: stripped when encoding from a Kotlin Instant (DER minimum encoding), + // but preserved verbatim when present in DER input, so the parser stays lenient and does + // not mangle. The Instant path trims, so these go through DER + String only (iso = null). ---- + + "fraction .120 — trailing zero preserved on DER/String parse, re-encoded verbatim" { + assertAllPaths("20520517130102.120Z", iso = null) + } + + "fraction .1200 — multiple trailing zeros preserved" { + assertAllPaths("20520517130102.1200Z", iso = null) + } + + "fraction .0120 — leading and trailing zeros both preserved" { + assertAllPaths("20520517130102.0120Z", iso = null) + } + + "encoding from a sub-second Instant strips trailing zeros (DER minimum encoding)" { + val t = Asn1Time(kotlin.time.Instant.parse("2052-05-17T13:01:02.120Z")) + t.shouldBeInstanceOf().fractionalSeconds shouldBe "12" + t.encodeToTlv().derEncoded shouldBe time("20520517130102.12Z") + } + + "the same value parsed from DER keeps the trailing zero and round-trips byte-identically" { + val der = time("20520517130102.120Z") + val t = decode(der) + t.shouldBeInstanceOf().fractionalSeconds shouldBe "120" + // .120 and .12 denote the same instant — only the encoded form differs + t.instant shouldBe kotlin.time.Instant.parse("2052-05-17T13:01:02.12Z") + t.encodeToTlv().derEncoded shouldBe der // NOT mangled down to ".12Z" + } + + "all-zero fraction is preserved verbatim (non-minimal but valid; must round-trip for signatures)" { + // .000Z denotes a whole second, but stripping it would change derEncoded and break signature + // verification over the original TBS bytes. So keep it as a Fractional and re-encode identically. + val der = time("20520517130102.000Z") + val t = decode(der) + t.shouldBeInstanceOf().fractionalSeconds shouldBe "000" + t.instant shouldBe kotlin.time.Instant.parse("2052-05-17T13:01:02Z") // whole-second value + t.encodeToTlv().derEncoded shouldBe der // NOT stripped to "...Z" + } + + "long all-zero fraction beyond nanosecond precision still round-trips byte-for-byte" { + assertAllPaths("20520517130102.000000000000000000000Z", iso = null) + } + + "a SecondsCapped and a whole-second Fractional (.000) are not equal (they encode differently)" { + val capped = decode(time("20520517130102Z")) + val fractional = decode(time("20520517130102.000Z")) + capped.shouldBeInstanceOf() + fractional.shouldBeInstanceOf() + capped.instant shouldBe fractional.instant // same instant + capped shouldNotBe fractional // but not equal, and symmetrically so + fractional shouldNotBe capped + } + + // ---- path-specific cases without a clean three-way equivalence ---- + + "UTC parses to SecondsCapped UTC (String/Instant paths are GeneralizedTime-only)" { + val t = decode(byteArrayOf(0x17, 13) + "240102030405Z".encodeToByteArray()) + t.shouldBeInstanceOf() + t.format shouldBe Asn1Time.Format.UTC + } + + "sub-second Instant + UTC override is rejected" { + shouldThrow { + Asn1Time(kotlin.time.Instant.parse("2024-01-02T03:04:05.050Z"), Asn1Time.Format.UTC) + } + } + } diff --git a/core/src/jvmTest/kotlin/at/asitplus/awesn1/fuzzed b/core/src/jvmTest/kotlin/at/asitplus/awesn1/fuzzed index 69de68da..648a6f22 160000 --- a/core/src/jvmTest/kotlin/at/asitplus/awesn1/fuzzed +++ b/core/src/jvmTest/kotlin/at/asitplus/awesn1/fuzzed @@ -1 +1 @@ -Subproject commit 69de68da3de9f4d15de2f7d8d307f6f47dee4ceb +Subproject commit 648a6f225f82fbfa49a36351149e5cc60bf496ec diff --git a/crypto/src/commonMain/kotlin/at/asitplus/awesn1/crypto/pki/X509TbsCertificate.kt b/crypto/src/commonMain/kotlin/at/asitplus/awesn1/crypto/pki/X509TbsCertificate.kt index cc30961a..fb409f45 100644 --- a/crypto/src/commonMain/kotlin/at/asitplus/awesn1/crypto/pki/X509TbsCertificate.kt +++ b/crypto/src/commonMain/kotlin/at/asitplus/awesn1/crypto/pki/X509TbsCertificate.kt @@ -97,8 +97,8 @@ data class X509TbsCertificate internal constructor( serialNumber: Asn1Integer, signatureAlgorithm: X509AlgorithmIdentifier, issuerName: List, - validFrom: Asn1Time, - validUntil: Asn1Time, + validFrom: Asn1Time.SecondsCapped, + validUntil: Asn1Time.SecondsCapped, subjectName: List, subjectPublicKeyInfo: SubjectPublicKeyInfo, issuerUniqueID: Asn1BitString? = null, diff --git a/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/CryptoDerRoundTripTest.kt b/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/CryptoDerRoundTripTest.kt index a67e0a4e..985f7f1b 100644 --- a/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/CryptoDerRoundTripTest.kt +++ b/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/CryptoDerRoundTripTest.kt @@ -197,8 +197,8 @@ private fun randomTbsCertificate(random: Random): X509TbsCertificate { serialNumber = Asn1Integer.fromByteArray(randomBytes(random, 12), Asn1Integer.Sign.POSITIVE), signatureAlgorithm = randomSignatureAlgorithmIdentifier(random), issuerName = List(random.nextInt(1, 3)) { randomRelativeDistinguishedName(random) }, - validFrom = Asn1Time(validFrom), - validUntil = Asn1Time(validUntil), + validFrom = Asn1Time.SecondsCapped(validFrom), + validUntil = Asn1Time.SecondsCapped(validUntil), subjectName = List(random.nextInt(1, 3)) { randomRelativeDistinguishedName(random) }, subjectPublicKeyInfo = randomSubjectPublicKeyInfo(random), issuerUniqueID = Asn1BitString(randomBytes(random, 8)).takeIf { random.nextBoolean() }, diff --git a/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/LegacyRegression.kt b/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/LegacyRegression.kt index 168d3203..4e0e4fc9 100644 --- a/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/LegacyRegression.kt +++ b/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/LegacyRegression.kt @@ -1,4 +1,5 @@ package at.asitplus.awesn1.crypto +import at.asitplus.awesn1.Asn1Time import at.asitplus.awesn1.Asn1Element import at.asitplus.awesn1.Asn1Integer @@ -31,7 +32,9 @@ import at.asitplus.awesn1.crypto.pki.X509Certificate import at.asitplus.awesn1.crypto.pki.X509CertificateExtension import at.asitplus.awesn1.serialization.ExplicitlyTagged import at.asitplus.awesn1.runWrappingAs +import at.asitplus.awesn1.serialization.DER import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromByteArray internal fun decodeLegacyAsCurrent(value: Any, encoded: ByteArray): Any { val element = Asn1Element.parse(encoded) @@ -166,24 +169,7 @@ private fun LegacyX509CertificateExtension.toCurrent() = ) private fun LegacyTbsCertificate.toCurrent() = - X509TbsCertificate( - version = version?.let { when(it) { - 0-> X509TbsCertificate.Version.V1 - 1-> X509TbsCertificate.Version.V2 - 2-> X509TbsCertificate.Version.V3 - else -> error("Unknown version $it") - } }, - serialNumber = Asn1Integer.fromByteArray(serialNumber, Sign.POSITIVE), - signatureAlgorithm = signatureAlgorithm.toCurrent(), - issuerName = issuerName.map { it.toCurrent() }, - validFrom = validFrom, - validUntil = validUntil, - subjectName = subjectName.map { it.toCurrent() }, - subjectPublicKeyInfo = subjectPublicKeyInfo.toCurrent(), - issuerUniqueID = issuerUniqueID, - subjectUniqueID = subjectUniqueID, - extensions = extensions?.map { it.toCurrent() }, - ) + DER.decodeFromByteArray(encodeToTlv().derEncoded) private fun LegacyX509Certificate.toCurrent() = X509Certificate( diff --git a/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/LenientBitStringTest.kt b/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/LenientBitStringTest.kt index b7ec4894..7c5f49eb 100644 --- a/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/LenientBitStringTest.kt +++ b/crypto/src/commonTest/kotlin/at/asitplus/awesn1/crypto/LenientBitStringTest.kt @@ -63,8 +63,8 @@ private fun minimalTbsCertificate( serialNumber = Asn1Integer(1u), signatureAlgorithm = X509AlgorithmIdentifier(ObjectIdentifier("1.2.840.113549.1.1.11"), emptyList()), issuerName = listOf(X500RelativeDistinguishedName(setOf(X500AttributeTypeAndValue.CommonName("issuer")))), - validFrom = Asn1Time(Instant.fromEpochSeconds(1_700_000_000L)), - validUntil = Asn1Time(Instant.fromEpochSeconds(1_700_086_400L)), + validFrom = Asn1Time.SecondsCapped(Instant.fromEpochSeconds(1_700_000_000L)), + validUntil = Asn1Time.SecondsCapped(Instant.fromEpochSeconds(1_700_086_400L)), subjectName = listOf(X500RelativeDistinguishedName(setOf(X500AttributeTypeAndValue.CommonName("subject")))), subjectPublicKeyInfo = SubjectPublicKeyInfo.ec(ObjectIdentifier("1.2.840.10045.3.1.7"), ByteArray(65) { it.toByte() }), issuerUniqueID = issuerUniqueID, diff --git a/crypto/src/jvmTest/kotlin/at/asitplus/awesn1/crypto/X509CertificateFixtureRoundTripTest.kt b/crypto/src/jvmTest/kotlin/at/asitplus/awesn1/crypto/X509CertificateFixtureRoundTripTest.kt index d2b657b5..c48c472c 100644 --- a/crypto/src/jvmTest/kotlin/at/asitplus/awesn1/crypto/X509CertificateFixtureRoundTripTest.kt +++ b/crypto/src/jvmTest/kotlin/at/asitplus/awesn1/crypto/X509CertificateFixtureRoundTripTest.kt @@ -13,6 +13,7 @@ import at.asitplus.awesn1.serialization.decodeFromPem import at.asitplus.awesn1.serialization.encodeToPem import at.asitplus.awesn1.serialization.encodeToPemBlock import at.asitplus.awesn1.serialization.encodeToTlv +import at.asitplus.testballoon.matrix.Indexes import at.asitplus.testballoon.matrix.matrixSuite import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldNotBeEmpty @@ -36,7 +37,8 @@ val X509CertificateFixtureRoundTripTest by matrixSuite { listOf(true, false).asData(name = "fixture kind", nameFn = { if (it) "OK only" else "Faulty only" }) - { ok -> val fixtures = certificateFixtures(ok) - data( fixtures, nameFn = { it.name }) test { path -> + if(ok) "empty" {} else + data( fixtures, nameFn = { it.name }, replay = if(!ok) Indexes(216L) else null) test { path -> fun parseAndAssert() { when (path.extension) {