Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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",
)
Loading