Skip to content

Commit 48716d4

Browse files
committed
feat: implemented bcrypt for js target
1 parent 851d176 commit 48716d4

7 files changed

Lines changed: 331 additions & 148 deletions

File tree

kotlin-js-store/yarn.lock

Lines changed: 137 additions & 142 deletions
Large diffs are not rendered by default.

module-extkt-crypto/build.gradle.kts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@ plugins {
55
}
66

77
kotlin {
8-
jvm {
9-
withJava()
10-
}
8+
jvm()
119
js {
1210
binaries.library()
13-
browser()
1411
nodejs()
12+
useEsModules()
1513
}
1614
sourceSets {
1715
commonMain {
@@ -22,6 +20,7 @@ kotlin {
2220
commonTest {
2321
dependencies {
2422
implementation(kotlin("test"))
23+
implementation(libs.kotlinx.coroutines.test)
2524
}
2625
}
2726
jvmMain {
@@ -30,5 +29,10 @@ kotlin {
3029
implementation(libs.favre.bcrypt)
3130
}
3231
}
32+
jsMain {
33+
dependencies {
34+
implementation(npm("bcryptjs", "^3.0.2"))
35+
}
36+
}
3337
}
3438
}

module-extkt-crypto/src/commonMain/kotlin/bcrypt/bcrypt.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
package org.cufy.bcrypt
22

3+
import kotlin.random.Random
4+
5+
expect fun bcryptSaltGen(): ByteArray
6+
7+
expect fun bcryptSaltGen(random: Random): ByteArray
8+
39
/**
410
* Hashes given password with the OpenBSD bcrypt schema.
511
* The cost factor will define how expensive the hash will
612
* be to generate.
713
*/
814
expect fun bcryptHash(password: String, cost: Int): String
915

16+
/**
17+
* Hashes given password with the OpenBSD bcrypt schema.
18+
* The cost factor will define how expensive the hash will
19+
* be to generate.
20+
*/
21+
expect fun bcryptHash(password: String, salt: ByteArray, cost: Int): String
22+
1023
/**
1124
* Verify given bcrypt hash, which includes salt and cost
1225
* factor with given raw password.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.cufy.test
2+
3+
import kotlinx.coroutines.test.runTest
4+
import org.cufy.bcrypt.bcryptCheck
5+
import org.cufy.bcrypt.bcryptHash
6+
import kotlin.test.Test
7+
import kotlin.test.assertEquals
8+
import kotlin.test.assertTrue
9+
10+
class BcryptTest {
11+
@Test
12+
fun hash() = runTest {
13+
val password = "Hello World"
14+
val salt = byteArrayOf(52, 74, -66, -18, 76, -60, 42, -91, 60, 51, 53, -70, -82, -70, -42, -1)
15+
val hash = "\$2a\$10\$LCo85ixCIoS6KxU4pppU9uRZBHut.S/unz55OOpSsBFosiGF8IN2S"
16+
val actualHash = bcryptHash(password, salt, cost = 10)
17+
18+
assertEquals(hash, actualHash)
19+
assertTrue(bcryptCheck(password, hash))
20+
}
21+
}
Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
11
package org.cufy.bcrypt
22

3+
import kotlin.random.Random
4+
5+
// stupid library that works; salt= salt + rounds
6+
@JsModule("bcryptjs")
7+
private external val bcryptjs: dynamic
8+
9+
actual fun bcryptSaltGen(): ByteArray {
10+
return Random.nextBytes(16)
11+
}
12+
13+
actual fun bcryptSaltGen(random: Random): ByteArray {
14+
return random.nextBytes(16)
15+
}
16+
317
actual fun bcryptHash(password: String, cost: Int): String {
4-
TODO("Not yet implemented")
18+
val config = bcryptjs.genSaltSync(cost)
19+
return bcryptjs.hashSync(password, config, cost)
20+
}
21+
22+
actual fun bcryptHash(password: String, salt: ByteArray, cost: Int): String {
23+
val costString = cost.toString().padStart(2, '0')
24+
val saltString = encodeRadix64(salt).decodeToString()
25+
val config = "$2a$${costString}$${saltString}"
26+
return bcryptjs.hashSync(password, config) as String
527
}
628

729
actual fun bcryptCheck(password: String, hash: String): Boolean {
8-
TODO("Not yet implemented")
30+
return bcryptjs.compareSync(password, hash)
931
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package org.cufy.bcrypt
2+
3+
private val D_MAP = byteArrayOf(
4+
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
5+
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
6+
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 1, 54, 55, 56, 57,
7+
58, 59, 60, 61, 62, 63, -1, -1, -1, -2, -1, -1, -1, 2, 3, 4, 5, 6, 7,
8+
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
9+
26, 27, -1, -1, -1, -1, -1, -1, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37,
10+
38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53
11+
)
12+
13+
private val E_MAP = charArrayOf(
14+
'.', '/', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
15+
'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V',
16+
'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h',
17+
'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
18+
'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5',
19+
'6', '7', '8', '9'
20+
).map { it.code.toByte() }
21+
22+
internal fun decodeRadix64(input: ByteArray): ByteArray? {
23+
// Ignore trailing '=' padding and whitespace from the input.
24+
var limit = input.size
25+
while (limit > 0) {
26+
val c = input[limit - 1]
27+
if (c != '='.code.toByte() && c != '\n'.code.toByte() && c != '\r'.code.toByte() && c != ' '.code.toByte() && c != '\t'.code.toByte()) {
28+
break
29+
}
30+
limit--
31+
}
32+
33+
// If the input includes whitespace, this output array will be longer than necessary.
34+
val out = ByteArray((limit * 6L / 8L).toInt())
35+
var outCount = 0
36+
var inCount = 0
37+
38+
var word = 0
39+
for (pos in 0..<limit) {
40+
val c = input[pos]
41+
42+
val bits: Int
43+
if (c == '.'.code.toByte() || c == '/'.code.toByte() || (c >= 'A'.code.toByte() && c <= 'z'.code.toByte()) || (c >= '0'.code.toByte() && c <= '9'.code.toByte())) {
44+
bits = D_MAP[c.toInt()].toInt()
45+
} else if (c == '\n'.code.toByte() || c == '\r'.code.toByte() || c == ' '.code.toByte() || c == '\t'.code.toByte()) {
46+
continue
47+
} else {
48+
throw IllegalArgumentException("invalid character to decode: $c")
49+
}
50+
51+
// Append this char's 6 bits to the word.
52+
word = (word shl 6) or bits.toByte().toInt()
53+
54+
// For every 4 chars of input, we accumulate 24 bits of output. Emit 3 bytes.
55+
inCount++
56+
if (inCount % 4 == 0) {
57+
out[outCount++] = (word shr 16).toByte()
58+
out[outCount++] = (word shr 8).toByte()
59+
out[outCount++] = word.toByte()
60+
}
61+
}
62+
63+
val lastWordChars = inCount % 4
64+
if (lastWordChars == 1) {
65+
// We read 1 char followed by "===". But 6 bits is a truncated byte! Fail.
66+
return ByteArray(0)
67+
} else if (lastWordChars == 2) {
68+
// We read 2 chars followed by "==". Emit 1 byte with 8 of those 12 bits.
69+
word = word shl 12
70+
out[outCount++] = (word shr 16).toByte()
71+
} else if (lastWordChars == 3) {
72+
// We read 3 chars, followed by "=". Emit 2 bytes for 16 of those 18 bits.
73+
word = word shl 6
74+
out[outCount++] = (word shr 16).toByte()
75+
out[outCount++] = (word shr 8).toByte()
76+
}
77+
78+
// If we sized our out array perfectly, we're done.
79+
if (outCount == out.size) return out
80+
81+
// Copy the decoded bytes to a new, right-sized array.
82+
return out.copyOf(outCount)
83+
}
84+
85+
internal fun encodeRadix64(input: ByteArray): ByteArray {
86+
val length = 4 * (input.size / 3) + (if (input.size % 3 == 0) 0 else input.size % 3 + 1)
87+
val out = ByteArray(length)
88+
var index = 0
89+
val end = input.size - input.size % 3
90+
var i = 0
91+
while (i < end) {
92+
out[index++] = E_MAP[(input[i].toInt() and 0xff) shr 2]
93+
out[index++] = E_MAP[((input[i].toInt() and 0x03) shl 4) or ((input[i + 1].toInt() and 0xff) shr 4)]
94+
out[index++] = E_MAP[((input[i + 1].toInt() and 0x0f) shl 2) or ((input[i + 2].toInt() and 0xff) shr 6)]
95+
out[index++] = E_MAP[(input[i + 2].toInt() and 0x3f)]
96+
i += 3
97+
}
98+
when (input.size % 3) {
99+
1 -> {
100+
out[index++] = E_MAP[(input[end].toInt() and 0xff) shr 2]
101+
out[index] = E_MAP[(input[end].toInt() and 0x03) shl 4]
102+
}
103+
104+
2 -> {
105+
out[index++] = E_MAP[(input[end].toInt() and 0xff) shr 2]
106+
out[index++] = E_MAP[((input[end].toInt() and 0x03) shl 4) or ((input[end + 1].toInt() and 0xff) shr 4)]
107+
out[index] = E_MAP[((input[end + 1].toInt() and 0x0f) shl 2)]
108+
}
109+
}
110+
return out
111+
}

module-extkt-crypto/src/jvmMain/kotlin/bcrypt/bcrypt.jvm.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,33 @@
11
package org.cufy.bcrypt
22

3+
import at.favre.lib.bytes.Bytes
34
import at.favre.lib.crypto.bcrypt.BCrypt
5+
import kotlin.random.Random
6+
import kotlin.random.asJavaRandom
47

58
private val BCRYPT_HASHER: BCrypt.Hasher = BCrypt.withDefaults()
69
private val BCRYPT_VERIFIER: BCrypt.Verifyer = BCrypt.verifyer()
710

11+
actual fun bcryptSaltGen(): ByteArray {
12+
return Bytes.random(BCrypt.SALT_LENGTH).array()
13+
}
14+
15+
actual fun bcryptSaltGen(random: Random): ByteArray {
16+
return Bytes.random(BCrypt.SALT_LENGTH, random.asJavaRandom()).array()
17+
}
18+
819
actual fun bcryptHash(password: String, cost: Int): String {
920
val pwdBytes = password.toByteArray()
1021
val out = BCRYPT_HASHER.hash(cost, pwdBytes)
1122
return out.decodeToString()
1223
}
1324

25+
actual fun bcryptHash(password: String, salt: ByteArray, cost: Int): String {
26+
val pwdBytes = password.toByteArray()
27+
val out = BCRYPT_HASHER.hash(cost, salt, pwdBytes)
28+
return out.decodeToString()
29+
}
30+
1431
actual fun bcryptCheck(password: String, hash: String): Boolean {
1532
val pwdBytes = password.toByteArray()
1633
val hashBytes = hash.toByteArray()

0 commit comments

Comments
 (0)