Skip to content

Commit

Permalink
Merge pull request #238 from aivanovski/feature/otp-support
Browse files Browse the repository at this point in the history
Add TOTP/HOTP support
  • Loading branch information
aivanovski authored Feb 25, 2024
2 parents 45b3814 + 83d5fce commit 5c5c1fd
Show file tree
Hide file tree
Showing 66 changed files with 2,291 additions and 195 deletions.
11 changes: 8 additions & 3 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def coreKtxVersion = '1.10.1'
def activityKtxVersion = '1.7.2'
def preferenceKtxVersion = '1.2.1'
def biometricVersion = '1.2.0-alpha05'
def liveDataKtxVersion = '2.6.2'
def liveDataKtxVersion = '2.7.0'

def okHttpVersion = '4.12.0'
def apacheCommonsLangVersion = '3.13.0'
Expand All @@ -200,6 +200,7 @@ def treeDiffVersion = '0.3.0'
def fzf4jVersion = '0.2.1'
// More latest version of jgit produce crash on Samsung
def jgitVersion = '6.2.0.202206071550-r'
def totpKtVersion = 'v1.0.0'

// Test
def jUnitVersion = '4.13.2'
Expand Down Expand Up @@ -251,13 +252,14 @@ dependencies {
implementation "androidx.biometric:biometric:$biometricVersion"
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion"
implementation "androidx.core:core-ktx:$coreKtxVersion"
implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:$liveDataKtxVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$liveDataKtxVersion"
implementation "androidx.activity:activity-ktx:$activityKtxVersion"
implementation "androidx.preference:preference-ktx:$preferenceKtxVersion"

// Compose
implementation platform("androidx.compose:compose-bom:2024.01.00")
implementation platform("androidx.compose:compose-bom:2024.02.00")
implementation "androidx.compose.material3:material3"
implementation "androidx.activity:activity-compose"

// Compose preview
implementation "androidx.compose.ui:ui-tooling-preview"
Expand Down Expand Up @@ -294,4 +296,7 @@ dependencies {

// Fuzzy search
implementation "com.github.aivanovski:fzf4j:$fzf4jVersion"

// TOTP + HOTP
implementation "com.github.robinohs:totp-kt:$totpKtVersion"
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import app.keemobile.kotpass.database.encode
import com.ivanovsky.passnotes.data.entity.PropertyType
import com.ivanovsky.passnotes.data.repository.file.databaseDsl.EntryEntity
import com.ivanovsky.passnotes.data.repository.file.databaseDsl.GroupEntity
import com.ivanovsky.passnotes.data.repository.file.databaseDsl.KotpassTreeDsl
import com.ivanovsky.passnotes.data.repository.file.databaseDsl.KotpassTreeDsl.tree
import java.io.ByteArrayOutputStream
import java.util.UUID

class FakeFileContentFactory {

fun createDefaultLocalDatabase(): ByteArray {
return KotpassTreeDsl.tree(ROOT) {
return tree(ROOT) {
group(GROUP_EMAIL)
group(GROUP_INTERNET) {
group(GROUP_CODING) {
Expand All @@ -37,7 +37,7 @@ class FakeFileContentFactory {
}

fun createDefaultRemoteDatabase(): ByteArray {
return KotpassTreeDsl.tree(ROOT) {
return tree(ROOT) {
group(GROUP_EMAIL)
group(GROUP_INTERNET) {
group(GROUP_CODING) {
Expand All @@ -64,6 +64,14 @@ class FakeFileContentFactory {
.toByteArray()
}

fun createDatabaseWithOtpData(): ByteArray {
return tree(ROOT) {
entry(ENTRY_TOTP)
entry(ENTRY_HOTP)
}
.toByteArray()
}

private fun KeePassDatabase.toByteArray(): ByteArray {
return ByteArrayOutputStream().use { out ->
this.encode(out)
Expand All @@ -80,6 +88,16 @@ class FakeFileContentFactory {
private val GROUP_SHOPPING = GroupEntity(title = "Shopping", uuid = UUID(100L, 5L))
private val GROUP_SOCIAL = GroupEntity(title = "Social", uuid = UUID(100L, 7L))

private val TOTP_URL = """
otpauth://totp/Example:john.doe?secret=AAAABBBBCCCCDDDD&period=30
&digits=6&issuer=Example&algorithm=SHA1
""".trimIndent()

private val HOTP_URL = """
otpauth://hotp/Example:john.doe?secret=AAAABBBBCCCCDDDD&digits=6
&issuer=Example&algorithm=SHA1&counter=1
""".trimIndent()

private val ENTRY_NAS_LOGIN = EntryEntity(
title = "NAS Login",
username = "john.doe",
Expand Down Expand Up @@ -193,5 +211,27 @@ class FakeFileContentFactory {
PropertyType.URL.propertyName to "https://amazon.com"
)
)

private val ENTRY_TOTP = EntryEntity(
title = "TOTP Entry",
username = "[email protected]",
password = "",
created = parseDate("2020-01-09"),
modified = parseDate("2020-01-09"),
custom = mapOf(
"otp" to TOTP_URL
)
)

private val ENTRY_HOTP = EntryEntity(
title = "HOTP Entry",
username = "[email protected]",
password = "",
created = parseDate("2020-01-09"),
modified = parseDate("2020-01-09"),
custom = mapOf(
"otp" to HOTP_URL
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ class FakeFileFactory(
return create(fsAuthority, FileUid.DEMO_MODIFIED, Time.REMOTE)
}

fun createOtpFile(): FileDescriptor {
return create(fsAuthority, FileUid.OTP, Time.LOCAL)
}

private fun create(
fsAuthority: FSAuthority,
uid: String,
Expand Down Expand Up @@ -106,6 +110,7 @@ class FakeFileFactory(
const val AUTO_TESTS = "auto-tests"
const val DEMO = "demo"
const val DEMO_MODIFIED = "demo-modified"
const val OTP = "otp"

val DEFAULT_UIDS = listOf(
NO_CHANGES,
Expand All @@ -119,7 +124,8 @@ class FakeFileFactory(
ERROR,
AUTO_TESTS,
DEMO,
DEMO_MODIFIED
DEMO_MODIFIED,
OTP
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ class FakeFileStorage(
val content = when (uid) {
FileUid.DEMO -> fileContentFactory.createDefaultLocalDatabase()
FileUid.DEMO_MODIFIED -> fileContentFactory.createDefaultRemoteDatabase()
FileUid.OTP -> fileContentFactory.createDatabaseWithOtpData()
else -> fileContentFactory.createDefaultLocalDatabase()
}

Expand Down Expand Up @@ -169,7 +170,8 @@ class FakeFileStorage(
fileFactory.createErrorFile(),
fileFactory.createAutoTestsFile(),
fileFactory.createDemoFile(),
fileFactory.createDemoModifiedFile()
fileFactory.createDemoModifiedFile(),
fileFactory.createOtpFile()
)
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.ivanovsky.passnotes.data.entity

enum class PropertyType(val propertyName: String) {
TITLE("Title"),
PASSWORD("Password"),
USER_NAME("UserName"),
URL("URL"),
NOTES("Notes"),
OTP("otp");

companion object {
val DEFAULT_TYPES = setOf(TITLE, PASSWORD, USER_NAME, URL, NOTES)

private val TYPES_MAP = mapOf(
TITLE.propertyName.lowercase() to TITLE,
PASSWORD.propertyName.lowercase() to PASSWORD,
USER_NAME.propertyName.lowercase() to USER_NAME,
URL.propertyName.lowercase() to URL,
NOTES.propertyName.lowercase() to NOTES,
OTP.propertyName.lowercase() to OTP
)

fun getByName(name: String?): PropertyType? {
val loweredName = name?.lowercase() ?: return null

return TYPES_MAP[loweredName]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.ivanovsky.passnotes.data.entity.Property
import com.ivanovsky.passnotes.data.entity.PropertyType
import com.ivanovsky.passnotes.data.repository.keepass.kotpass.model.InheritableOptions
import com.ivanovsky.passnotes.domain.entity.PropertyFilter
import com.ivanovsky.passnotes.domain.otp.OtpUriFactory
import com.ivanovsky.passnotes.extensions.toByteString
import com.ivanovsky.passnotes.util.StringUtils.EMPTY
import java.time.Instant
Expand All @@ -34,10 +35,12 @@ fun GroupOverride.convertToInheritableOption(parentValue: Boolean): InheritableB
isEnabled = true,
isInheritValue = false
)

GroupOverride.Disabled -> InheritableBooleanOption(
isEnabled = false,
isInheritValue = false
)

GroupOverride.Inherit -> InheritableBooleanOption(
isEnabled = parentValue,
isInheritValue = true
Expand Down Expand Up @@ -87,7 +90,7 @@ fun RawEntry.convertToNote(
val attachments = mutableListOf<Attachment>()

for (field in fields.entries) {
val type = PropertyType.getByName(field.key)
val type = determinePropertyType(field.key, field.value.content)

properties.add(
Property(
Expand Down Expand Up @@ -125,6 +128,20 @@ fun RawEntry.convertToNote(
)
}

private fun determinePropertyType(name: String, value: String): PropertyType? {
val type = PropertyType.getByName(name) ?: return null

return if (type == PropertyType.OTP) {
if (OtpUriFactory.parseUri(value) != null) {
PropertyType.OTP
} else {
null
}
} else {
type
}
}

private fun RawEntry.getCreationTime(): Long {
val created = times?.creationTime
val modified = times?.lastModificationTime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class FilterDefaultTypesStrategy : PropertyFilterStrategy {
PropertyType.TITLE,
PropertyType.USER_NAME,
PropertyType.PASSWORD,
PropertyType.OTP,
PropertyType.URL,
PropertyType.NOTES
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ class SortedByTypeStrategy : PropertyFilterStrategy {
EnumMap<PropertyType, Int>(PropertyType::class.java).apply {
put(PropertyType.USER_NAME, 1)
put(PropertyType.PASSWORD, 2)
put(PropertyType.URL, 3)
put(PropertyType.NOTES, 4)
put(PropertyType.OTP, 3)
put(PropertyType.URL, 4)
put(PropertyType.NOTES, 5)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.ivanovsky.passnotes.domain.otp

import com.ivanovsky.passnotes.domain.otp.model.HashAlgorithmType
import dev.robinohs.totpkt.otp.HashAlgorithm

fun HashAlgorithmType.toExternalAlgorithm(): HashAlgorithm {
return when (this) {
HashAlgorithmType.SHA1 -> HashAlgorithm.SHA1
HashAlgorithmType.SHA256 -> HashAlgorithm.SHA256
HashAlgorithmType.SHA512 -> HashAlgorithm.SHA512
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.ivanovsky.passnotes.domain.otp

import com.ivanovsky.passnotes.domain.otp.model.OtpToken
import dev.robinohs.totpkt.otp.hotp.HotpGenerator as ExternalHotpGenerator

class HotpGenerator(
override val token: OtpToken
) : OtpGenerator {

private val generator = ExternalHotpGenerator(
algorithm = token.algorithm.toExternalAlgorithm(),
codeLength = token.digits
)

override fun generateCode(): String {
return generator.generateCode(token.secret.toByteArray(), token.counter ?: 0)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.ivanovsky.passnotes.domain.otp

import com.ivanovsky.passnotes.util.StringUtils.splitIntoWords

object OtpCodeFormatter {

fun format(code: String): String {
val length = code.length

return when {
length < 6 -> code
length % 3 == 0 -> splitIntoWords(code, wordLength = 3)
length % 4 == 0 -> splitIntoWords(code, wordLength = 4)
length == 7 -> splitIntoWords(code, wordLength = 4)
else -> splitIntoWords(code, wordLength = 4)
}
}
}
Loading

0 comments on commit 5c5c1fd

Please sign in to comment.