diff --git a/CHANGELOG.md b/CHANGELOG.md index 2673721..2ee2971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Implement Base64 URL-safe encoding according to [RFC 4648 §5](https://datatracker.ietf.org/doc/html/rfc4648#section-5) ## [1.0.4] - 2022-04-26 ### Changed diff --git a/kase64/src/commonMain/kotlin/saschpe/kase64/Base64.kt b/kase64/src/commonMain/kotlin/saschpe/kase64/Base64.kt index fe933c0..9189c16 100644 --- a/kase64/src/commonMain/kotlin/saschpe/kase64/Base64.kt +++ b/kase64/src/commonMain/kotlin/saschpe/kase64/Base64.kt @@ -15,74 +15,42 @@ */ package saschpe.kase64 -internal const val BASE64_SET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" -private val RX_BASE64_CLEANER = "[^=$BASE64_SET${"]".toRegex()}" - /** - * Base64 encode a string. + * Encode a [String] to Base64 standard encoded [String]. + * + * See [RFC 4648 §4](https://datatracker.ietf.org/doc/html/rfc4648#section-4) */ val String.base64Encoded: String - get() { - val pad = when (length % 3) { - 1 -> "==" - 2 -> "=" - else -> "" - } - val raw = this + 0.toChar().toString().repeat(maxOf(0, pad.length)) - return raw.chunkedSequence(3) { - Triple(it[0].code, it[1].code, it[2].code) - }.map { (first, second, third) -> - (0xFF.and(first) shl 16) + (0xFF.and(second) shl 8) + 0xFF.and(third) - }.map { n -> - sequenceOf((n shr 18) and 0x3F, (n shr 12) and 0x3F, (n shr 6) and 0x3F, n and 0x3F) - }.flatten() - .map { BASE64_SET[it] } - .joinToString("") - .dropLast(pad.length) + pad - } - -fun ByteArray.asCharArray(): CharArray { - val chars = CharArray(size) - for (i in chars.indices) { - chars[i] = get(i).toInt().toChar() - } - return chars -} + get() = encodeInternal(Encoding.Standard) /** - * Base64 encode a ByteArray to String. + * Encode a [ByteArray] to Base64 standard encoded [String]. + * + * See [RFC 4648 §4](https://datatracker.ietf.org/doc/html/rfc4648#section-4) */ val ByteArray.base64Encoded: String get() = asCharArray().concatToString().base64Encoded -private fun String.base64DecodeInternal(): Sequence { - require(length % 4 == 0) { "The string \"$this\" does not comply with Base64 length requirement." } - return replace(RX_BASE64_CLEANER, "") - .replace("=", "A") - .chunkedSequence(4) { - (BASE64_SET.indexOf(it[0]) shl 18) + - (BASE64_SET.indexOf(it[1]) shl 12) + - (BASE64_SET.indexOf(it[2]) shl 6) + - BASE64_SET.indexOf(it[3]) - } - .map { sequenceOf(0xFF.and(it shr 16), 0xFF.and(it shr 8), 0xFF.and(it)) } - .flatten() -} - /** - * Decode a Base64 string to String. + * Decode a Base64 standard encoded [String] to [String]. + * + * See [RFC 4648 §4](https://datatracker.ietf.org/doc/html/rfc4648#section-4) */ val String.base64Decoded: String - get() = base64DecodeInternal().map { it.toChar() }.joinToString("").dropLast(count { it == '=' }) + get() = decodeInternal(Encoding.Standard).map { it.toChar() }.joinToString("").dropLast(count { it == '=' }) /** - * Decode a Base64 string to ByteArray. + * Decode a Base64 standard encoded [String] to [ByteArray]. + * + * See [RFC 4648 §4](https://datatracker.ietf.org/doc/html/rfc4648#section-4) */ val String.base64DecodedBytes: ByteArray - get() = base64DecodeInternal().map { it.toByte() }.toList().dropLast(count { it == '=' }).toByteArray() + get() = decodeInternal(Encoding.Standard).map { it.toByte() }.toList().dropLast(count { it == '=' }).toByteArray() /** - * Decode a Base64 ByteArray to String. + * Decode a Base64 standard encoded [ByteArray] to [String]. + * + * See [RFC 4648 §4](https://datatracker.ietf.org/doc/html/rfc4648#section-4) */ val ByteArray.base64Decoded: String get() = asCharArray().concatToString().base64Decoded diff --git a/kase64/src/commonMain/kotlin/saschpe/kase64/Base64Internal.kt b/kase64/src/commonMain/kotlin/saschpe/kase64/Base64Internal.kt new file mode 100644 index 0000000..8787d66 --- /dev/null +++ b/kase64/src/commonMain/kotlin/saschpe/kase64/Base64Internal.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2022 Sascha Peilicke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package saschpe.kase64 + +/** + * Base64 encoding scheme + */ +internal sealed interface Encoding { + val alphabet: String + val requiresPadding: Boolean + + object Standard : Encoding { + override val alphabet: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + override val requiresPadding: Boolean = true + } + + object UrlSafe : Encoding { + override val alphabet: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + override val requiresPadding: Boolean = false // Padding is optional + } +} + +internal fun String.encodeInternal(encoding: Encoding): String { + val padLength = when (length % 3) { + 1 -> 2 + 2 -> 1 + else -> 0 + } + val raw = this + 0.toChar().toString().repeat(maxOf(0, padLength)) + val encoded = raw.chunkedSequence(3) { + Triple(it[0].code, it[1].code, it[2].code) + }.map { (first, second, third) -> + (0xFF.and(first) shl 16) + (0xFF.and(second) shl 8) + 0xFF.and(third) + }.map { n -> + sequenceOf((n shr 18) and 0x3F, (n shr 12) and 0x3F, (n shr 6) and 0x3F, n and 0x3F) + }.flatten() + .map { encoding.alphabet[it] } + .joinToString("") + .dropLast(padLength) + return when (encoding.requiresPadding) { + true -> encoded.padEnd(encoded.length + padLength, '=') + else -> encoded + } +} + +internal fun String.decodeInternal(encoding: Encoding): Sequence { + val padLength = when (length % 4) { + 1 -> 3 + 2 -> 2 + 3 -> 1 + else -> 0 + } + return padEnd(length + padLength, '=') + .replace("=", "A") + .chunkedSequence(4) { + (encoding.alphabet.indexOf(it[0]) shl 18) + (encoding.alphabet.indexOf(it[1]) shl 12) + + (encoding.alphabet.indexOf(it[2]) shl 6) + encoding.alphabet.indexOf(it[3]) + } + .map { sequenceOf(0xFF.and(it shr 16), 0xFF.and(it shr 8), 0xFF.and(it)) } + .flatten() +} + +internal fun ByteArray.asCharArray(): CharArray { + val chars = CharArray(size) + for (i in chars.indices) { + chars[i] = get(i).toInt().toChar() + } + return chars +} diff --git a/kase64/src/commonMain/kotlin/saschpe/kase64/Base64Url.kt b/kase64/src/commonMain/kotlin/saschpe/kase64/Base64Url.kt new file mode 100644 index 0000000..0ce92ad Binary files /dev/null and b/kase64/src/commonMain/kotlin/saschpe/kase64/Base64Url.kt differ diff --git a/kase64/src/commonTest/kotlin/saschpe/kase64/Base64InternalTest.kt b/kase64/src/commonTest/kotlin/saschpe/kase64/Base64InternalTest.kt new file mode 100644 index 0000000..7ba0acb Binary files /dev/null and b/kase64/src/commonTest/kotlin/saschpe/kase64/Base64InternalTest.kt differ diff --git a/kase64/src/commonTest/kotlin/saschpe/kase64/Base64Test.kt b/kase64/src/commonTest/kotlin/saschpe/kase64/Base64Test.kt index 0b03ef0..c040f55 100644 Binary files a/kase64/src/commonTest/kotlin/saschpe/kase64/Base64Test.kt and b/kase64/src/commonTest/kotlin/saschpe/kase64/Base64Test.kt differ diff --git a/kase64/src/commonTest/kotlin/saschpe/kase64/Base64UrlTest.kt b/kase64/src/commonTest/kotlin/saschpe/kase64/Base64UrlTest.kt new file mode 100644 index 0000000..2e46fb8 --- /dev/null +++ b/kase64/src/commonTest/kotlin/saschpe/kase64/Base64UrlTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2022 Sascha Peilicke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package saschpe.kase64 + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class Base64UrlTest { + @Test + fun byteArray_base64UrlDecoded() { + assertEquals("Hello, world!", "SGVsbG8sIHdvcmxkIQ==".encodeToByteArray().base64UrlDecoded) + assertContentEquals( + byteArrayOf( + -94, 124, -26, -112, -72, -84, 16, 11, 67, -45, 107, 38, -99, 79, 62, -49, 83, 26, -85, -70, -122, 53, + 67, 42, -94, -87, 61, -74, 66, 0, 80, -125, -17, -11, -125, 63, 109, -15, 56, -95, -33, 18, 110, 47, + 47, -20, -72, -34, 53, -69, 49, -45, 54, 53, -21, 43, 9, -84, -125, 72, -61, 76, 31, -46 + ), + "onzmkLisEAtD02smnU8-z1Maq7qGNUMqoqk9tkIAUIPv9YM_bfE4od8Sbi8v7LjeNbsx0zY16ysJrINIw0wf0g==".base64UrlDecodedBytes + ) + } + + @Test + fun byteArray_base64UrlEncoded() { + assertEquals( + "xvrp9DBWlei2mG0ov9MN-A", // value1 + byteArrayOf(-58, -6, -23, -12, 48, 86, -107, -24, -74, -104, 109, 40, -65, -45, 13, -8).base64UrlEncoded + ) + assertEquals( + "IkYJxF8nIQD9RY7Yk6r26A", // value222 + byteArrayOf(34, 70, 9, -60, 95, 39, 33, 0, -3, 69, -114, -40, -109, -86, -10, -24).base64UrlEncoded + ) + assertEquals( + "U0GeVBi2dNcdL2IO0nJo5Q", // value555 + byteArrayOf(83, 65, -98, 84, 24, -74, 116, -41, 29, 47, 98, 14, -46, 114, 104, -27).base64UrlEncoded + ) + } + + @Test + fun string_base64UrlDecoded() { + assertEquals("word", "d29yZA==".base64UrlDecoded) + assertEquals("Word", "V29yZA==".base64UrlDecoded) + assertEquals("Hello", "SGVsbG8=".base64UrlDecoded) + assertEquals("World!", "V29ybGQh".base64UrlDecoded) + assertEquals("Hello, world!", "SGVsbG8sIHdvcmxkIQ==".base64UrlDecoded) + assertEquals( + Encoding.Standard.alphabet, + "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw==".base64UrlDecoded + ) + assertEquals("Salt", "U2FsdA==".base64UrlDecoded) + assertEquals("Pepper", "UGVwcGVy".base64UrlDecoded) + assertEquals("abcd", "YWJjZA".base64UrlDecoded) + assertEquals( + "1234567890-=!@#\$%^&*()_+qwertyuiop[];'\\,./?><|\":}{P`~", + "MTIzNDU2Nzg5MC09IUAjJCVeJiooKV8rcXdlcnR5dWlvcFtdOydcLC4vPz48fCI6fXtQYH4".base64UrlDecoded + ) + assertEquals("saschpe", "c2FzY2hwZQ".base64UrlDecoded) + } + + @Test + fun string_base64UrlEncoded() { + assertEquals("d29yZA", "word".base64UrlEncoded) + assertEquals("V29yZA", "Word".base64UrlEncoded) + assertEquals("SGVsbG8", "Hello".base64UrlEncoded) + assertEquals("V29ybGQh", "World!".base64UrlEncoded) + assertEquals("SGVsbG8sIHdvcmxkIQ", "Hello, world!".base64UrlEncoded) + assertEquals("SGVsbG8sIHdvcmxkIQ", "Hello, world!".encodeToByteArray().base64UrlEncoded) + assertEquals( + "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw", + Encoding.Standard.alphabet.base64UrlEncoded + ) + assertEquals("U2FsdA", "Salt".base64UrlEncoded) + assertEquals("UGVwcGVy", "Pepper".base64UrlEncoded) + assertEquals("YWJjZA", "abcd".base64UrlEncoded) + assertEquals( + "MTIzNDU2Nzg5MC09IUAjJCVeJiooKV8rcXdlcnR5dWlvcFtdOydcLC4vPz48fCI6fXtQYH4", + "1234567890-=!@#\$%^&*()_+qwertyuiop[];'\\,./?><|\":}{P`~".base64UrlEncoded + ) + assertEquals("c2FzY2hwZQ", "saschpe".base64UrlEncoded) + } + + @Test + fun string_roundTrip() = + assertEquals(Encoding.UrlSafe.alphabet, Encoding.UrlSafe.alphabet.base64UrlEncoded.base64UrlDecoded) +}