Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ metrics/
/pyasn1/*/*/__pycache__/
/core/src/jvmTest/pyasn1/
repo
/core/collected.txt
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ObjectIdentifier>`
* **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.
Expand Down
247 changes: 175 additions & 72 deletions core/src/commonMain/kotlin/at/asitplus/awesn1/Asn1Time.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Asn1Primitive> {

val instant = Instant.fromEpochSeconds(instant.epochSeconds)
sealed class Asn1Time : Asn1Encodable<Asn1Primitive> {

/**
* 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..<THRESHOLD_GENERALIZED_TIME) {
Format.GENERALIZED
} else {
Format.UTC
}
abstract val instant: Instant

companion object : Asn1Serializer<Asn1Primitive, Asn1Time>(
leadingTags = setOf(Asn1Element.Tag.TIME_UTC, Asn1Element.Tag.TIME_GENERALIZED),
decodable = object : Asn1Decodable<Asn1Primitive, Asn1Time> {
@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)
Comment thread
nodh marked this conversation as resolved.

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 {
Expand All @@ -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<Asn1Primitive, Asn1Time>(
leadingTags = setOf(Asn1Element.Tag.TIME_UTC, Asn1Element.Tag.TIME_GENERALIZED),
decodable = object : Asn1Decodable<Asn1Primitive, Asn1Time> {
@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]). */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: This line is longer than 120 chars, so it might get reformatted in another PR leading to unrelated changes there

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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: This line is longer than 120 chars, so it might get reformatted in another PR leading to unrelated changes there

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..<THRESHOLD_GENERALIZED_TIME) Asn1Time.Format.GENERALIZED
else Asn1Time.Format.UTC

private fun fromUtc(content: ByteArray): Asn1Time =
Asn1Time.SecondsCapped(Instant.decodeUtcTimeFromAsn1ContentBytes(content), Asn1Time.Format.UTC)


/**
* String serializer for [Asn1Time] used for interoperability with non-DER serialization formats.
*
* When used with the `awesn1.kxs` DER format, this serializer is bypassed and UTC/GeneralizedTime are
* encoded/decoded using proper DER TLV.
* In non-DER formats this serializer stores only the [Instant], so the original UTC-vs-Generalized
* ASN.1 time choice is not preserved.
* In non-DER formats this serializer stores only nanosecond precision, and the
* UTC-vs-Generalized choice is not preserved.
*/
internal object Asn1TimeSerializer : KSerializer<Asn1Time> {
override val descriptor: SerialDescriptor =
Expand All @@ -146,3 +244,8 @@ internal object Asn1TimeSerializer : KSerializer<Asn1Time> {
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)
19 changes: 17 additions & 2 deletions core/src/commonMain/kotlin/at/asitplus/awesn1/ObjectIdentifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ import kotlin.uuid.Uuid
class ObjectIdentifier @Throws(Asn1Exception::class) private constructor(
bytes: ByteArray?,
nodes: List<VarUInt>?
) :
Asn1Encodable<Asn1Primitive> {
) : Asn1Encodable<Asn1Primitive>, Comparable<ObjectIdentifier> {
init {
if ((bytes == null) && (nodes == null)) {
//we're not even declaring this, since this is an implementation error on our end
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has nothing todo with the PR title

* 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]
*/
Expand Down
Loading