taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit c4c352c2133350d86f3ebe32be8e97da5cff5480
parent cd74fa6d5a6849eed5f1949eadc9e791678349a2
Author: Marc Stibane <marc@taler.net>
Date:   Thu, 13 Aug 2026 16:41:40 +0200

AI: guard timestamp overflow

Diffstat:
Mtaler-swift/Sources/taler-swift/Time.swift | 28++++++++++++++++++++++------
Mtaler-swift/Tests/taler-swiftTests/TimeTests.swift | 31+++++++++++++++++++++++++++++++
2 files changed, 53 insertions(+), 6 deletions(-)

diff --git a/taler-swift/Sources/taler-swift/Time.swift b/taler-swift/Sources/taler-swift/Time.swift @@ -18,6 +18,9 @@ enum TimestampError: Error { /// The value `never` cannot be returned as UInt64. case invalidUInt64Value + + /// The number of seconds is outside the range the protocol allows. + case invalidSecondsValue } // MARK: - /// A point in time, represented by milliseconds from January 1, 1970.. @@ -30,14 +33,20 @@ public enum Timestamp: Codable, Hashable, Sendable { case t_ms = "t_ms" } + /// The protocol declares every timestamp as an integer capped at 2^53-1. + static let maxSeconds: UInt64 = (1 << 53) - 1 + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + if let seconds: UInt64 = try? container.decode(UInt64.self, forKey: .t_s) { + // Reject out of range instead of letting `seconds * 1000` overflow, which + // would trap - a crash no do/catch around the decode could survive. + guard seconds <= Self.maxSeconds else { throw TimestampError.invalidSecondsValue } + self = Timestamp.milliseconds(seconds * 1000) + return + } do { - if let seconds: UInt64 = try? container.decode(UInt64.self, forKey: .t_s) { - self = Timestamp.milliseconds(seconds * 1000) - } else { - self = Timestamp.milliseconds(try container.decode(UInt64.self, forKey: .t_ms)) - } + self = Timestamp.milliseconds(try container.decode(UInt64.self, forKey: .t_ms)) } catch { // rethrows or never let stringValue = try container.decode(String.self, forKey: .t_s) if stringValue == "never" { @@ -294,7 +303,14 @@ public func -(lhs: Timestamp, rhs: Timestamp) throws -> Duration { if lhs_ms < rhs_ms { return Duration.microseconds(0) } else { - return Duration.microseconds((lhs_ms - rhs_ms) * 1000) + // A difference of more than ~585000 years cannot be expressed in + // microseconds. Saturate like + and - with a Duration do, don't trap. + let result = (lhs_ms - rhs_ms).multipliedReportingOverflow(by: 1000) + if result.overflow { + return Duration.forever + } else { + return Duration.microseconds(result.partialValue) + } } case .never: throw TimestampError.invalidArithmeticArguments diff --git a/taler-swift/Tests/taler-swiftTests/TimeTests.swift b/taler-swift/Tests/taler-swiftTests/TimeTests.swift @@ -34,6 +34,21 @@ class TimeTests: XCTestCase { XCTAssertThrowsError(try JSONDecoder().decode(Timestamp.self, from: json2)) } + func testParsingOutOfRangeSeconds() { + // The protocol caps a timestamp at 2^53-1 seconds, which must still decode... + let maxSeconds: UInt64 = (1 << 53) - 1 + let json1 = "{ \"t_s\" : \(maxSeconds) }".data(using: .utf8)! + let t: Timestamp = try! JSONDecoder().decode(Timestamp.self, from: json1) + XCTAssertEqual(t, Timestamp.milliseconds(maxSeconds * 1000)) + + // ...while anything above it must be rejected. Multiplying it by 1000 unchecked + // overflows UInt64 and traps, which no do/catch around the decode can survive. + for tooBig in [maxSeconds + 1, 20_000_000_000_000_000, UInt64.max] { + let json = "{ \"t_s\" : \(tooBig) }".data(using: .utf8)! + XCTAssertThrowsError(try JSONDecoder().decode(Timestamp.self, from: json)) + } + } + func testSerialize() { // Encoding is lossy: milliseconds are truncated to whole seconds (see bugs.txt), // so a round trip only holds for whole-second timestamps. @@ -98,4 +113,20 @@ class TimeTests: XCTestCase { XCTAssert(try! Timestamp.milliseconds(12309487) - Timestamp.milliseconds(12309488) == Duration.microseconds((0) * 1000)) XCTAssertThrowsError(try Timestamp.milliseconds(12309487) - Timestamp.never) } + + func testTimestampDifferenceOverflow() { + // t_s = 2^53-1 is a timestamp the protocol allows, so it can arrive from any + // exchange or merchant. Its distance from now doesn't fit into microseconds: + // the difference must saturate to forever, not trap. + let maxSeconds: UInt64 = (1 << 53) - 1 + let farFuture = Timestamp.milliseconds(maxSeconds * 1000) + XCTAssert(try! farFuture - Timestamp.milliseconds(0) == Duration.forever) + XCTAssert(try! farFuture.duration() == Duration.forever) + XCTAssert(try! Timestamp.milliseconds(UInt64.max) - Timestamp.milliseconds(0) == Duration.forever) + + // right below the boundary the exact difference is still returned + let maxDiffMs = UInt64.max / 1000 + XCTAssert(try! Timestamp.milliseconds(maxDiffMs) - Timestamp.milliseconds(0) == Duration.microseconds(maxDiffMs * 1000)) + XCTAssert(try! Timestamp.milliseconds(maxDiffMs + 1) - Timestamp.milliseconds(0) == Duration.forever) + } }