diff --git a/Sources/FeistelCipher/Cipher.swift b/Sources/FeistelCipher/Cipher.swift index b5470a7..5dbf39f 100644 --- a/Sources/FeistelCipher/Cipher.swift +++ b/Sources/FeistelCipher/Cipher.swift @@ -175,8 +175,7 @@ public struct FeistelCipher: Sendable { /// ``` public func encode(_ value: UInt64, withChecksum: Bool = true) -> String { let data = encrypt(value) - return withChecksum - ? CrockfordEncoder.encodeWithChecksum(data) : CrockfordEncoder.encode(data) + return CrockfordEncoder.encode(data, withChecksum: withChecksum) } /// Encrypts a value and encodes it as a Crockford Base32 string padded to a fixed length. @@ -226,22 +225,35 @@ public struct FeistelCipher: Sendable { /// /// - Parameter value: A Crockford Base32 string previously produced by ``encode(_:withChecksum:)`` /// or ``encode(_:length:withChecksum:)``. + /// - Parameters: + /// - value: A Crockford Base32 string previously produced by ``encode(_:withChecksum:)`` + /// or ``encode(_:length:withChecksum:)``. + /// - withChecksum: When `true` (the default), the trailing check character is validated + /// before decoding. Set to `false` when decoding tokens that were encoded with + /// `withChecksum: false`. /// - Returns: The original plain integer. /// - Throws: /// - ``FeistelCipherError/emptyToken`` if the token is empty after normalisation. /// - ``FeistelCipherError/invalidCharacter(_:)`` if the token contains a character outside /// the Crockford Base32 alphabet that could not be corrected automatically. - /// - ``FeistelCipherError/checksumMismatch`` if the trailing check character does not match. + /// - ``FeistelCipherError/checksumMismatch`` if `withChecksum` is `true` and the trailing + /// check character does not match. /// /// ## Example /// ```swift /// let cipher = FeistelCipher(key: 722628) - /// try cipher.decode("99G7GB6QCKZBH0") // 1 - /// try cipher.decode("0099G7GB6QCKZBH0") // 1 (padded token, same result) - /// try cipher.decode("99G7GB6QCKZBH9") // throws FeistelCipherError.checksumMismatch + /// + /// // Default — checksum validated + /// try cipher.decode("99G7GB6QCKZBH0") // 1 + /// + /// // Token encoded without checksum — must decode the same way + /// let token = cipher.encode(1, withChecksum: false) + /// try cipher.decode(token, withChecksum: false) // 1 /// ``` - public func decode(_ value: String) throws(FeistelCipherError) -> UInt64 { - let decoded = try CrockfordEncoder.decode(value) + public func decode(_ value: String, withChecksum: Bool = true) throws(FeistelCipherError) + -> UInt64 + { + let decoded = try CrockfordEncoder.decode(value, withChecksum: withChecksum) return decrypt(decoded) } diff --git a/Sources/FeistelCipher/CrockfordEncoder.swift b/Sources/FeistelCipher/CrockfordEncoder.swift index adfc358..098a25f 100644 --- a/Sources/FeistelCipher/CrockfordEncoder.swift +++ b/Sources/FeistelCipher/CrockfordEncoder.swift @@ -44,8 +44,11 @@ struct CrockfordEncoder { /// - ``FeistelCipherError/emptyToken`` if the token is empty after normalisation. /// - ``FeistelCipherError/invalidCharacter(_:)`` if the token contains a character outside /// the Crockford Base32 alphabet that could not be corrected automatically. - /// - ``FeistelCipherError/checksumMismatch`` if the trailing check character does not match. - static func decode(_ input: String) throws(FeistelCipherError) -> UInt64 { + /// - ``FeistelCipherError/checksumMismatch`` if `withChecksum` is `true` and the trailing + /// check character does not match. + static func decode(_ input: String, withChecksum: Bool = true) throws(FeistelCipherError) + -> UInt64 + { let clean = input.uppercased() .replacing("-", with: "") .replacing("O", with: "0") @@ -54,9 +57,17 @@ struct CrockfordEncoder { guard !clean.isEmpty else { throw .emptyToken } - // 1. Separate the value from the check symbol (last character) - let valuePart = String(clean.dropLast()) - let providedCheckChar = clean.last! + // 1. Separate the value from the optional check symbol + let valuePart: String + let providedCheckChar: Character? + + if withChecksum { + valuePart = String(clean.dropLast()) + providedCheckChar = clean.last + } else { + valuePart = clean + providedCheckChar = nil + } // 2. Decode the value part, throwing on any unrecognised character var numericValue: UInt64 = 0 @@ -68,13 +79,14 @@ struct CrockfordEncoder { numericValue * 32 + UInt64(alphabet.distance(from: alphabet.startIndex, to: index)) } - // 3. Validate the check symbol - let expectedCheckIndex = Int(numericValue % 37) - let expectedCheckChar = checkSymbols[ - checkSymbols.index(checkSymbols.startIndex, offsetBy: expectedCheckIndex)] - - guard providedCheckChar == expectedCheckChar else { - throw .checksumMismatch + // 3. Validate the check symbol (only when withChecksum is true) + if let checkChar = providedCheckChar { + let expectedCheckIndex = Int(numericValue % 37) + let expectedCheckChar = checkSymbols[ + checkSymbols.index(checkSymbols.startIndex, offsetBy: expectedCheckIndex)] + guard checkChar == expectedCheckChar else { + throw .checksumMismatch + } } return numericValue @@ -82,6 +94,21 @@ struct CrockfordEncoder { // MARK: - Encoding + /// Encodes a `UInt64` value as a Crockford Base32 string, optionally appending a + /// modulo-37 check character. + /// + /// This is the unified entry point that mirrors ``decode(_:withChecksum:)``. + /// Pass `withChecksum: true` to append a check character, or `false` for a raw + /// Base32 string with no trailing check symbol. + /// + /// - Parameters: + /// - value: The `UInt64` value to encode. + /// - withChecksum: When `true`, a modulo-37 check character is appended. + /// - Returns: A Crockford Base32 string. + static func encode(_ value: UInt64, withChecksum: Bool) -> String { + withChecksum ? encodeWithChecksum(value) : encode(value) + } + /// Encodes a `UInt64` value as a Crockford Base32 string and appends a modulo-37 check /// character to the end. /// diff --git a/Tests/FeistelCipherTests/CrockfordEncoderTests.swift b/Tests/FeistelCipherTests/CrockfordEncoderTests.swift index 8167164..a1c17f8 100644 --- a/Tests/FeistelCipherTests/CrockfordEncoderTests.swift +++ b/Tests/FeistelCipherTests/CrockfordEncoderTests.swift @@ -23,6 +23,27 @@ struct CrockfordEncoderTests { } } + @Test func decodeWithoutChecksum() async throws { + // encode (no checksum) → decode (no checksum) must round-trip + let value: UInt64 = 1_234_567_890 + let encoded = CrockfordEncoder.encode(value) + let decoded = try CrockfordEncoder.decode(encoded, withChecksum: false) + #expect(decoded == value) + } + + @Test func decodeWithChecksumFalseIgnoresLastChar() async throws { + // When withChecksum is false every character is treated as a value digit — + // passing a token that HAS a checksum would decode to a different (larger) number + let value: UInt64 = 1_234_567_890 + let withCheck = CrockfordEncoder.encodeWithChecksum(value) // e.g. "14SC0PJV" + let withoutCheck = CrockfordEncoder.encode(value) // e.g. "14SC0PJ" + let decodedFull = try CrockfordEncoder.decode(withoutCheck, withChecksum: false) + #expect(decodedFull == value) + // The checksum-bearing token decoded without checksum would be a different value + let decodedFull2 = try CrockfordEncoder.decode(withCheck, withChecksum: false) + #expect(decodedFull2 != value) + } + @Test func formatForCopying() async throws { let encoded = "1234567890" let formatted = CrockfordEncoder.formatForCopying(encoded) diff --git a/Tests/FeistelCipherTests/FeistelCipherTests.swift b/Tests/FeistelCipherTests/FeistelCipherTests.swift index 036472b..dd634a2 100644 --- a/Tests/FeistelCipherTests/FeistelCipherTests.swift +++ b/Tests/FeistelCipherTests/FeistelCipherTests.swift @@ -43,6 +43,35 @@ struct FeistelCipherTests { #expect(encoded == "9G0X9D4P5QCWW") } + @Test func testDecodeWithoutChecksumRoundTrip() async throws { + // encode(withChecksum: false) + decode(withChecksum: false) must return the original value + for id: UInt64 in [1, 42, 1_000, 1_234_567] { + let token = cipher.encode(id, withChecksum: false) + let decoded = try cipher.decode(token, withChecksum: false) + #expect(decoded == id) + } + } + + @Test func testDecodeWithChecksumTrueFailsOnChecksumlessToken() async throws { + // A token encoded without checksum must NOT decode successfully with checksum: true, + // because its last value character is consumed as (almost certainly wrong) check char + let token = cipher.encode(1, withChecksum: false) + #expect(throws: FeistelCipherError.checksumMismatch) { + try cipher.decode(token, withChecksum: true) + } + } + + @Test func testBitWidth50DecodeWithoutChecksumRoundTrip() async throws { + // The canonical 10-char use-case: bitWidth 50, no checksum, fixed length + let cipher50 = FeistelCipher(key: 722628, bitWidth: 50) + for id: UInt64 in [1, 42, 1_000, 1_000_000] { + let token = cipher50.encode(id, length: 10, withChecksum: false) + #expect(token.count == 10) + let decoded = try cipher50.decode(token, withChecksum: false) + #expect(decoded == id) + } + } + // MARK: - Fixed-length encode overload @Test func testEncodeWithLengthPadsWithLeadingZeros() async throws {