From 1c4263c33106e0926bcb13686bb0b87a490cdd1b Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Mon, 21 Sep 2026 06:38:55 -0600 Subject: [PATCH] fix(mpp): validate Kotlin expires as RFC 3339 OffsetDateTime.parse reads ISO 8601, not RFC 3339 section 5.6: it accepts hour-only offsets, missing seconds, an empty fraction and extended years, and it refuses leap seconds, fractions past nine digits and offsets past 18:00. isExpired now parses expires against the 5.6 grammar. A leap second is accepted only at 23:59:60 UTC on the last day of a month and clamps to the last nanosecond of :59. Anything refused still reads as expired. --- .../solana/paykit/protocols/mpp/core/Types.kt | 53 +++++++-- .../protocols/mpp/core/ExpiresRfc3339Test.kt | 110 ++++++++++++++++++ 2 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/core/ExpiresRfc3339Test.kt diff --git a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Types.kt b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Types.kt index 0747fee53..acdac203d 100644 --- a/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Types.kt +++ b/kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/Types.kt @@ -3,15 +3,54 @@ package com.solana.paykit.protocols.mpp.core import com.solana.paykit.paycore.* import kotlinx.serialization.Serializable +import java.time.DateTimeException import java.time.Instant -import java.time.OffsetDateTime -import java.time.format.DateTimeParseException +import java.time.LocalDate +import java.time.ZoneOffset // MppException lives in paycore (crypto primitives need it, and protocols need // crypto) and is brought into scope by the wildcard import above. No same-package // typealias here: aliasing a sealed class within its own consuming package // shadows the import and breaks resolution of its nested members. +private val RFC3339_DATE_TIME = Regex( + """(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:[Zz]|([+-])(\d{2}):(\d{2}))""", +) + +/** RFC 3339 §5.7: `:60` is a leap second only at 23:59:60 UTC on the last day of a month. */ +private const val LEAP_SECOND_UTC_SECOND_OF_DAY = 23 * 3600 + 59 * 60 + 59 + +/** + * Parses [raw] against the RFC 3339 §5.6 `date-time` grammar, returning null if + * it does not match. A leap second is clamped to `23:59:59.999999999`, which is + * the closest instant `java.time` can represent. + */ +private fun parseRfc3339(raw: String): Instant? { + val g = RFC3339_DATE_TIME.matchEntire(raw)?.groupValues ?: return null + val second = g[6].toInt() + val offsetHour = g[9].ifEmpty { "0" }.toInt() + val offsetMinute = g[10].ifEmpty { "0" }.toInt() + if (second > 60 || offsetHour > 23 || offsetMinute > 59) return null + val local = try { + LocalDate.of(g[1].toInt(), g[2].toInt(), g[3].toInt()) + .atTime(g[4].toInt(), g[5].toInt(), minOf(second, 59)) + } catch (_: DateTimeException) { + return null + } + val offsetSeconds = (offsetHour * 3600 + offsetMinute * 60) * if (g[8] == "-") -1 else 1 + val epochSecond = local.toEpochSecond(ZoneOffset.UTC) - offsetSeconds + if (second == 60) { + val utcDate = LocalDate.ofEpochDay(epochSecond.floorDiv(86_400L)) + if (epochSecond.mod(86_400L) != LEAP_SECOND_UTC_SECOND_OF_DAY.toLong() || + utcDate.dayOfMonth != utcDate.lengthOfMonth() + ) { + return null + } + return Instant.ofEpochSecond(epochSecond, 999_999_999L) + } + return Instant.ofEpochSecond(epochSecond, g[7].take(9).padEnd(9, '0').toLong()) +} + /** Parsed MPP `WWW-Authenticate` challenge. */ @Serializable data class PaymentChallenge( @@ -31,16 +70,12 @@ data class PaymentChallenge( * Returns true if this challenge has an `expires` timestamp that is at or * before [now] (audit #10). A challenge with no `expires` is never expired * (the spec allows omitting it). FAIL-CLOSED: an `expires` value that is - * present but does not parse as an RFC3339 / ISO-8601 offset timestamp is - * treated as expired, so a malformed timestamp cannot bypass the refusal. + * present but is not an RFC 3339 `date-time` is treated as expired, so a + * malformed timestamp cannot bypass the refusal. */ fun isExpired(now: Instant = Instant.now()): Boolean { val raw = expires ?: return false - val expiresAt = try { - OffsetDateTime.parse(raw).toInstant() - } catch (_: DateTimeParseException) { - return true - } + val expiresAt = parseRfc3339(raw) ?: return true return !expiresAt.isAfter(now) } diff --git a/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/core/ExpiresRfc3339Test.kt b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/core/ExpiresRfc3339Test.kt new file mode 100644 index 000000000..0402ff118 --- /dev/null +++ b/kotlin/src/test/kotlin/com/solana/paykit/protocols/mpp/core/ExpiresRfc3339Test.kt @@ -0,0 +1,110 @@ +package com.solana.paykit.protocols.mpp.core + +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Table over the shared RFC 3339 `expires` conformance corpus and the §5.8 + * examples. Validity reads back as `!isExpired` at [Instant.MIN], since an + * `expires` that is not a `date-time` is expired. + */ +class ExpiresRfc3339Test { + private fun challenge(expires: String) = + PaymentChallenge( + id = "i", + realm = "r", + method = "solana", + intent = "charge", + request = "q", + expires = expires, + ) + + @Test + fun corpusVerdicts() { + for (input in ACCEPTED) { + assertFalse(challenge(input).isExpired(Instant.MIN), input) + } + for (input in REJECTED) { + assertTrue(challenge(input).isExpired(Instant.MIN), input) + } + } + + @Test + fun offsetPastJavaTimeBoundStillShiftsTheInstant() { + val challenge = challenge("2026-01-29T12:00:00+23:00") + assertTrue(challenge.isExpired(Instant.parse("2026-01-28T13:00:00Z"))) + assertFalse(challenge.isExpired(Instant.parse("2026-01-28T12:59:59Z"))) + } + + @Test + fun offsetMinutesShiftTheInstant() { + val challenge = challenge("1937-01-01T12:00:27.87+00:20") + assertTrue(challenge.isExpired(Instant.parse("1937-01-01T11:40:27.87Z"))) + assertFalse(challenge.isExpired(Instant.parse("1937-01-01T11:40:27.869999999Z"))) + } + + @Test + fun leapSecondClampsToTheLastRepresentableNanosecond() { + val challenge = challenge("1990-12-31T15:59:60-08:00") + assertTrue(challenge.isExpired(Instant.parse("1990-12-31T23:59:59.999999999Z"))) + assertFalse(challenge.isExpired(Instant.parse("1990-12-31T23:59:59.999999998Z"))) + } +} + +private val ACCEPTED = listOf( + "1985-04-12T23:20:50.52Z", + "1996-12-19T16:39:57-08:00", + "1937-01-01T12:00:27.87+00:20", + "1990-12-31T23:59:60Z", + "1990-12-31T15:59:60-08:00", + "1998-12-31T23:59:60Z", + "1998-12-31T15:59:60.123-08:00", + "1972-06-30T23:59:60Z", + "1999-01-01T00:59:60+01:00", + "2021-09-29T16:04:33.0000000000Z", + "2021-09-29T16:04:33.0000000001Z", + "2021-09-29T16:04:33.0123456789Z", + "2021-09-29T16:04:33.1000000000Z", + "2021-09-29T16:04:33.1000000009Z", + "2021-09-29T16:04:33.9999999999Z", + "2021-09-29T16:04:33.00123456789Z", + "2021-09-29T16:04:33.10000000000Z", + "2021-09-29T16:04:33.000123456789Z", + "2021-09-29T16:04:33.9999999999999999Z", + "1985-04-12T00:59:59.999999999999999Z", + "2026-01-29T12:00:00.1234567890Z", + "2026-01-29T12:00:00.9999999999999999999Z", + "2026-01-29T12:00:00+23:00", + "2026-01-29T12:00:00-23:00", + "1963-06-19t08:30:06.283185z", + "0000-01-01T00:00:00Z", +) + +private val REJECTED = listOf( + "", + "+12026-01-29T12:00:00Z", + "2026-01-29T12:00:00+01", + "1996-12-19T16:39:57-08", + "2021-09-29T16:04:33.Z", + "2021-09-29T16:04:33,5Z", + "2026-01-29T12:00Z", + "2026-01-29T12:00:00", + "2026-01-29T12:00:00+0000", + "2026-01-29T24:00:00Z", + "2026-02-29T00:00:00Z", + "2026-02-30T00:00:00Z", + "2026-04-31T00:00:00Z", + "2026-01-29", + "23:59:60Z", + "06/19/1963 08:30:06 PST", + "2026-01-29T12:00:00+24:00", + "2026-01-29T12:00:00-24:00", + "2026-01-29T12:00:00+00:60", + "1990-12-31T10:00:00+10:60", + "1998-12-30T23:59:60Z", + "1998-12-31T23:58:60Z", + "1998-12-31T22:59:60Z", + "1999-01-01T00:59:60+02:00", +)