Skip to content

Commit 77583b2

Browse files
authored
Merge pull request #1064 from FlowCrypt/ip-1061-port-zxcvbn
issue #1061 Port zxcvbnStrengthBar
2 parents 2404609 + d7acdb5 commit 77583b2

3 files changed

Lines changed: 248 additions & 1 deletion

File tree

.idea/modules.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
* © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com
3+
* Contributors: ivan
4+
*/
5+
6+
package com.flowcrypt.email.security.pgp
7+
8+
import java.math.BigDecimal
9+
import java.math.BigInteger
10+
import java.math.RoundingMode
11+
import java.security.SecureRandom
12+
import java.util.concurrent.TimeUnit.DAYS
13+
import java.util.concurrent.TimeUnit.HOURS
14+
import java.util.concurrent.TimeUnit.MINUTES
15+
16+
object PgpPwd {
17+
data class Word(
18+
val match: String,
19+
val word: String,
20+
val bar: Long,
21+
var color: String,
22+
val pass: Boolean
23+
)
24+
25+
data class PwdStrengthResult(
26+
val word: Word,
27+
val seconds: BigInteger,
28+
val time: String
29+
)
30+
31+
enum class PwdType {
32+
PASSPHRASE,
33+
PASSWORD
34+
}
35+
36+
fun estimateStrength(guesses: BigInteger, type: PwdType = PwdType.PASSPHRASE): PwdStrengthResult {
37+
val timeToCrack = guesses.divideAndRemainder(CRACK_GUESSES_PER_SECOND)
38+
if (timeToCrack[1] >= HALF_CRACK_GUESSES_PER_SECOND) {
39+
timeToCrack[0] = timeToCrack[0].inc()
40+
}
41+
val readableTime = readableCrackTime(timeToCrack[0])
42+
val words = when (type) {
43+
PwdType.PASSPHRASE -> CRACK_TIME_WORDS_PASSPHRASE
44+
PwdType.PASSWORD -> CRACK_TIME_WORDS_PASSWORD
45+
}
46+
for (word in words) {
47+
if (readableTime.contains(word.match)) {
48+
return PwdStrengthResult(word, timeToCrack[0], readableTime)
49+
}
50+
}
51+
throw IllegalArgumentException("Can't estimate strength for the number of guesses $guesses")
52+
}
53+
54+
/**
55+
* Generates random password using digits and uppercase English letters, for example:
56+
* TDW6-DU5M-TANI-LJXY
57+
*/
58+
fun random(): String {
59+
val bytes = ByteArray(16)
60+
val rnd = SecureRandom()
61+
rnd.nextBytes(bytes)
62+
return bytesToPassword(bytes)
63+
}
64+
65+
fun bytesToPassword(bytes: ByteArray): String {
66+
val minLength = 16
67+
if (bytes.size < minLength) {
68+
throw IllegalArgumentException(
69+
"Source byte array is too short: required minimum length is $minLength, " +
70+
"but the actual length is ${bytes.size}"
71+
)
72+
}
73+
val s = StringBuilder()
74+
bytes.forEachIndexed { i, b0 ->
75+
if (i > 0 && i % 4 == 0) s.append('-')
76+
var b = b0 % 36
77+
if (b < 0) b += 36
78+
s.append(if (b < 10) '0' + b else 'A' + (b - 10))
79+
}
80+
return s.toString()
81+
}
82+
83+
// https://stackoverflow.com/questions/8211744/convert-time-interval-given-in-seconds-into-more-human-readable-form
84+
private fun readableCrackTime(totalSeconds: BigInteger): String {
85+
val n = BigDecimal(totalSeconds)
86+
val millennia = n.div(SECONDS_PER_MILLENNIUM).setScale(0, RoundingMode.HALF_UP)
87+
if (millennia > BigDecimal.ZERO) {
88+
return if (millennia == BigDecimal.ONE) "a millennium" else "millennia"
89+
}
90+
91+
val centuries = n.div(SECONDS_PER_CENTURY).setScale(0, RoundingMode.HALF_UP)
92+
if (centuries > BigDecimal.ZERO) {
93+
return if (centuries == BigInteger.ONE) "a century" else "centuries"
94+
}
95+
96+
val years = n.div(SECONDS_PER_YEAR).setScale(0, RoundingMode.HALF_UP)
97+
if (years > BigDecimal.ZERO) {
98+
return "$years year${numberWordEnding(years)}"
99+
}
100+
101+
val months = n.div(SECONDS_PER_MONTH).setScale(0, RoundingMode.HALF_UP)
102+
if (months > BigDecimal.ZERO) {
103+
return "$months month${numberWordEnding(months)}"
104+
}
105+
106+
val weeks = n.div(SECONDS_PER_WEEK).setScale(0, RoundingMode.HALF_UP)
107+
if (weeks > BigDecimal.ZERO) {
108+
return "$weeks week${numberWordEnding(weeks)}"
109+
}
110+
111+
val days = n.div(SECONDS_PER_DAY).setScale(0, RoundingMode.HALF_UP)
112+
if (days > BigDecimal.ZERO) {
113+
return "$days day${numberWordEnding(days)}"
114+
}
115+
116+
val hours = n.div(SECONDS_PER_HOUR).setScale(0, RoundingMode.HALF_UP)
117+
if (hours > BigDecimal.ZERO) {
118+
return "$hours hour${numberWordEnding(hours)}"
119+
}
120+
121+
val minutes = n.div(SECONDS_PER_MINUTE).setScale(0, RoundingMode.HALF_UP)
122+
if (minutes > BigDecimal.ZERO) {
123+
return "$minutes minute${numberWordEnding(minutes)}"
124+
}
125+
126+
if (n > BigDecimal.ZERO) {
127+
return "$n second${numberWordEnding(n)}"
128+
}
129+
130+
return "less than a second"
131+
}
132+
133+
private fun numberWordEnding(n: BigDecimal): String {
134+
return if (n > BigDecimal.ONE) "s" else ""
135+
}
136+
137+
// (10k pc)*(2 core p/pc)*(4k guess p/core)
138+
// https://www.abuse.ch/?p=3294
139+
// https://threatpost.com/how-much-does-botnet-cost-022813/77573/
140+
// https://www.abuse.ch/?p=3294
141+
private val CRACK_GUESSES_PER_SECOND = BigInteger.valueOf(10000 * 2 * 4000)
142+
private val HALF_CRACK_GUESSES_PER_SECOND = CRACK_GUESSES_PER_SECOND.div(BigInteger.valueOf(2))
143+
144+
private val SECONDS_PER_MILLENNIUM = DAYS.toSeconds(365 * 100 * 1000).toBigDecimal()
145+
private val SECONDS_PER_CENTURY = DAYS.toSeconds(365 * 100).toBigDecimal()
146+
private val SECONDS_PER_YEAR = DAYS.toSeconds(365).toBigDecimal()
147+
private val SECONDS_PER_MONTH = DAYS.toSeconds(30).toBigDecimal()
148+
private val SECONDS_PER_WEEK = DAYS.toSeconds(7).toBigDecimal()
149+
private val SECONDS_PER_DAY = DAYS.toSeconds(1).toBigDecimal()
150+
private val SECONDS_PER_HOUR = HOURS.toSeconds(1).toBigDecimal()
151+
private val SECONDS_PER_MINUTE = MINUTES.toSeconds(1).toBigDecimal()
152+
153+
private val CRACK_TIME_WORDS_PASSWORD = arrayOf(
154+
// the requirements for a one-time password are less strict
155+
Word(match = "millenni", word = "perfect", bar = 100, color = "green", pass = true),
156+
Word(match = "centu", word = "perfect", bar = 95, color = "green", pass = true),
157+
Word(match = "year", word = "great", bar = 80, color = "orange", pass = true),
158+
Word(match = "month", word = "good", bar = 70, color = "darkorange", pass = true),
159+
Word(match = "week", word = "good", bar = 30, color = "darkred", pass = true),
160+
Word(match = "day", word = "reasonable", bar = 40, color = "darkorange", pass = true),
161+
Word(match = "hour", word = "bare minimum", bar = 20, color = "darkred", pass = true),
162+
Word(match = "minute", word = "poor", bar = 15, color = "red", pass = false),
163+
Word(match = "", word = "weak", bar = 10, color = "red", pass = false)
164+
)
165+
166+
private val CRACK_TIME_WORDS_PASSPHRASE = arrayOf(
167+
// the requirements for a pass phrase are meant to be strict
168+
Word(match = "millenni", word = "perfect", bar = 100, color = "green", pass = true),
169+
Word(match = "centu", word = "great", bar = 80, color = "green", pass = true),
170+
Word(match = "year", word = "good", bar = 60, color = "orange", pass = true),
171+
Word(match = "month", word = "reasonable", bar = 40, color = "darkorange", pass = true),
172+
Word(match = "week", word = "poor", bar = 30, color = "darkred", pass = false),
173+
Word(match = "day", word = "poor", bar = 20, color = "darkred", pass = false),
174+
Word(match = "", word = "weak", bar = 10, color = "red", pass = false)
175+
)
176+
177+
@Suppress("unused")
178+
val weakWords = listOf(
179+
"crypt", "up", "cryptup", "flow", "flowcrypt", "encryption", "pgp", "email", "set",
180+
"backup", "passphrase", "best", "pass", "phrases", "are", "long", "and", "have",
181+
"several", "words", "in", "them", "Best pass phrases are long", "have several words",
182+
"in them", "bestpassphrasesarelong", "haveseveralwords", "inthem",
183+
"Loss of this pass phrase", "cannot be recovered", "Note it down", "on a paper",
184+
"lossofthispassphrase", "cannotberecovered", "noteitdown", "onapaper", "setpassword",
185+
"set password", "set pass word", "setpassphrase", "set pass phrase", "set passphrase"
186+
)
187+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com
3+
* Contributors: ivan
4+
*/
5+
6+
package com.flowcrypt.email.security.pgp
7+
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Assert.assertTrue
10+
import org.junit.Test
11+
import java.math.BigInteger
12+
13+
class PgpPwdTest {
14+
@Test
15+
fun testEstimateStrength() {
16+
val actualResult = PgpPwd.estimateStrength(
17+
BigInteger("88946283684264"), PgpPwd.PwdType.PASSPHRASE)
18+
val expectedResult = PgpPwd.PwdStrengthResult(
19+
word = PgpPwd.Word(
20+
match = "week",
21+
word = "poor",
22+
bar = 30,
23+
color = "darkred",
24+
pass = false
25+
),
26+
seconds = BigInteger.valueOf(1111829),
27+
time = "2 weeks"
28+
)
29+
assertEquals(expectedResult, actualResult)
30+
}
31+
32+
@Test
33+
fun testBytesToPassword() {
34+
val bytes = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 10, 11, 12, 13, 14, 15)
35+
assertEquals("1234-5678-90AB-CDEF", PgpPwd.bytesToPassword(bytes))
36+
}
37+
38+
@Test
39+
fun testBytesToPasswordRejectsTooShortByteArray() {
40+
val bytes = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)
41+
42+
// I'd better use assertThrows(), but the strange thing happens:
43+
// in the IntelliJ it resolves fine, but during compilation it says
44+
// something like "Unresolved symbol assertThrows"
45+
try {
46+
PgpPwd.bytesToPassword(bytes)
47+
throw Exception("IllegalArgumentException not thrown")
48+
} catch (ex: IllegalArgumentException) {
49+
// this is expected
50+
}
51+
}
52+
53+
private val passwordRegex = Regex("[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}")
54+
55+
@Test
56+
fun testRandom() {
57+
val password = PgpPwd.random()
58+
assertTrue("Password structure mismatch", passwordRegex.matches(password))
59+
}
60+
}

0 commit comments

Comments
 (0)