Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add URL-safe encoding #4

Merged
merged 4 commits into from
May 19, 2022
Merged
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
9 changes: 8 additions & 1 deletion .idea/runConfigurations/build.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Changelog
All notable changes to this project will be documented in this file.

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]

## [1.0.5] - 2022-05-19
### 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
- Dependency update:
- [Kotlin-1.6.21](https://github.com/JetBrains/kotlin/releases/tag/v1.6.21)
- [Gradle-7.4.2](https://docs.gradle.org/7.4.2/release-notes.html)

## [1.0.3] - 2022-03-10
### Changed
- Gitlab: Publish Android variants, fix iOS simulator tests

## [1.0.2] - 2022-03-04
### Added
- Kotlin/Multiplatform: Enable JavaScript for NodeJS

## [1.0.1] - 2022-03-04
### Added
- GPG sign releases

## [1.0.0] - 2022-03-04
### Added
- Initial implementation of Base64 standard encoding
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ repositories {
}

dependencies {
implementation("de.peilicke.sascha:kase64:1.0.4")
implementation("de.peilicke.sascha:kase64:1.0.5")
}
```

Expand Down
2 changes: 1 addition & 1 deletion kase64/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ android {
}

group = "de.peilicke.sascha"
version = "1.0.4"
version = "1.0.5"

val javadocJar by tasks.registering(Jar::class) {
archiveClassifier.set("javadoc")
Expand Down
68 changes: 18 additions & 50 deletions kase64/src/commonMain/kotlin/saschpe/kase64/Base64.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int> {
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
82 changes: 82 additions & 0 deletions kase64/src/commonMain/kotlin/saschpe/kase64/Base64Internal.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2022 Sascha Peilicke <[email protected]>
*
* 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<Int> {
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
}
Binary file not shown.
Binary file not shown.
Binary file modified kase64/src/commonTest/kotlin/saschpe/kase64/Base64Test.kt
Binary file not shown.
98 changes: 98 additions & 0 deletions kase64/src/commonTest/kotlin/saschpe/kase64/Base64UrlTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright 2022 Sascha Peilicke <[email protected]>
*
* 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)
}
5 changes: 4 additions & 1 deletion scripts/release
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# Constants
FILES=(
kase64/build.gradle.kts
README.md
)

# Functions
Expand Down Expand Up @@ -60,6 +59,10 @@ done
sed2 "s/kase64:.*\"/kase64:${version_name_new}\"/" README.md
git_commit_files="${git_commit_files} README.md"

# Update CHANGELOG.md
sed2 "s/\[Unreleased\]/\[Unreleased\]\n\n## [${version_name_new}] - $(date "+%Y-%m-%d")/" CHANGELOG.md
git_commit_files="${git_commit_files} CHANGELOG.md"

# Create a commit with appropriate tag
git_commit_message="Release version ${version_name_new}"
safe git commit --signoff ${git_commit_files} -m "${git_commit_message}"
Expand Down