Skip to content

Commit

Permalink
feat: Add keepass database (#183)
Browse files Browse the repository at this point in the history
  • Loading branch information
behzodhalil committed Feb 15, 2024
1 parent 483faa2 commit 4481f03
Show file tree
Hide file tree
Showing 31 changed files with 923 additions and 191 deletions.
1 change: 1 addition & 0 deletions crypto/cipher/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ kotlin {
val commonMain by getting {
dependencies {
implementation(projects.crypto.secureRandom)
implementation(projects.crypto.digest)
}
}
val commonTest by getting {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.spherelabs.crypto.cipher

import io.spherelabs.crypto.hash.sha256


object AesKdf {
fun transformKey(
key: ByteArray,
seed: ByteArray,
rounds: ULong,
): ByteArray {
for (r in 0 until rounds.toLong()) {
AES(seed)[CipherMode.ECB, CipherPadding.NoPadding].encrypt(key, offset = 0, len = 16)
AES(seed)[CipherMode.ECB, CipherPadding.NoPadding].encrypt(key, offset = 16, len = 16)
}
return key.sha256().also {
key.clear()
}
}
}


fun ByteArray.clear() {
for (i in indices) this[i] = 0x0
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
package io.spherelabs.crypto.cipher


/**
*
* Argon2 is a password-hashing function that summarizes the state of the art in the design of memory-hard functions
* and can be used to hash passwords for credential storage, key derivation, or other applications.
*
* It has a simple design aimed at the highest memory filling rate and effective use of multiple computing units,
* while still providing defense against tradeoff attacks (by exploiting the cache and memory organization of the recent processors).
*
* Argon2 has three variants: Argon2i, Argon2d, and Argon2id. Argon2d is faster and uses data-depending memory access,
* which makes it highly resistant against GPU cracking attacks and suitable for applications with no threats from side-channel timing attacks (eg. cryptocurrencies).
* Argon2i instead uses data-independent memory access, which is preferred for password hashing and password-based key derivation,
* but it is slower as it makes more passes over the memory to protect from tradeoff attacks. Argon2id is a hybrid of Argon2i and Argon2d,
* using a combination of data-depending and data-independent memory accesses,
* which gives some of Argon2i's resistance to side-channel cache timing attacks and much of Argon2d's resistance to GPU cracking attacks.
*
* Argon2i, Argon2d, and Argon2id are parametrized by:
*
* A time cost, which defines the amount of computation realized and therefore the execution time, given in number of iterations
* A memory cost, which defines the memory usage, given in kibibytes
* A parallelism degree, which defines the number of parallel threads
*/
private const val Argon2BlockSize = 1024
private const val Argon2QwordsInBlock = Argon2BlockSize / 8
private const val Argon2AddressesInBlock = 128
Expand All @@ -16,7 +37,7 @@ private const val M32L = 0xFFFFFFFFL

private val ZeroBytes = ByteArray(4)

internal class Argon2(
class Argon2Engine(
private val type: Type = Type.Argon2D,
private val version: Version = Version.Ver13,
private val salt: ByteArray,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.spherelabs.crypto.cipher

object Argon2Kdf {
fun transformKey(
type: Argon2Engine.Type,
version: Argon2Engine.Version,
password: ByteArray,
secretKey: ByteArray?,
additional: ByteArray?,
salt: ByteArray,
iterations: ULong,
parallelism: UInt,
memory: ULong
): ByteArray {
val result = ByteArray(32)
Argon2Engine(
type = type,
salt = salt,
secret = secretKey,
additional = additional,
iterations = iterations.toInt(),
parallelism = parallelism.toInt(),
memory = memory.toInt() / 1024,
version = version
).encrypt(password, result)

return result
}
}
37 changes: 13 additions & 24 deletions crypto/cipher/src/commonTest/kotlin/Argon2Test.kt
Original file line number Diff line number Diff line change
@@ -1,43 +1,32 @@
import io.spherelabs.anycrypto.securerandom.SecureRandom
import io.spherelabs.anycrypto.securerandom.buildSecureRandom
import io.spherelabs.crypto.cipher.Argon2
import kotlin.test.BeforeTest
import io.spherelabs.crypto.cipher.Argon2Engine
import kotlin.test.Test
import kotlin.test.assertEquals

class Argon2Test {

private lateinit var random: SecureRandom

@BeforeTest
fun setup() {
random = buildSecureRandom()
}

@OptIn(ExperimentalStdlibApi::class)
@Test
fun `GIVEN the argon2 WHEN generate hash value THEN equals same value`() {
val randomOutput = random.nextBytes(32)
val result = "011ed3fc11e41d40bceb3d48e3443417a57a9f7bbfce2c9360ff56956edc9fe4"
val password = "1dc787126c3261d3891e016a7d6022f60589a982b006ee5006da7eb35b1261ac"
val randomOutput = ByteArray(32)

val salt = random.nextBytes(16)
val salt = "8c4402516655e458b6935cf609292512".encodeToByteArray()
val secret = "b89290567f720edbc3bbbf1171e435ca".encodeToByteArray()
val additional = "7958dbdb5c5f47bb29e1a585e7ae4c96".encodeToByteArray()

val plainText = random.nextBytes(32)
val secret = random.nextBytes(16)
val additional = random.nextBytes(16)

Argon2(
type = Argon2.Type.Argon2Id,
version = Argon2.Version.Ver13,
Argon2Engine(
type = Argon2Engine.Type.Argon2Id,
version = Argon2Engine.Version.Ver13,
salt = salt,
secret = secret,
additional = additional,
iterations = 3,
parallelism = 4,
memory = 32,
).encrypt(plainText, randomOutput)
).encrypt(password.encodeToByteArray(), randomOutput)

val expected = "346a6a7563f3d03b0a95548e93cdafe0f61d7d7ae55f4ebc58b0c404cec220f2"

println(randomOutput.toHexString())
assertEquals(result, randomOutput.toHexString())
assertEquals(expected, randomOutput.toHexString())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ class AndroidSecureRandom(
secureRandom.nextBytes(byteArray)
return byteArray
}

override fun nextBytes(byteArray: ByteArray): ByteArray {
secureRandom.nextBytes(byteArray)
return byteArray
}
}

actual fun buildSecureRandom(): SecureRandom = AndroidSecureRandom(JvmSecureRandom())
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.spherelabs.anycrypto.securerandom

interface SecureRandom {
fun nextBytes(size: Int): ByteArray
fun nextBytes(byteArray: ByteArray): ByteArray
}

expect fun buildSecureRandom(): SecureRandom
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal class IosSecureRandom : SecureRandom {

if (size != 0) {
result.usePinned { pin ->
val address = pin.addressOf(0)
val address = pin.addressOf(0)

SecRandomCopyBytes(
kSecRandomDefault,
Expand All @@ -26,6 +26,21 @@ internal class IosSecureRandom : SecureRandom {

return result
}

override fun nextBytes(byteArray: ByteArray): ByteArray {
if (byteArray.size != 0) {
byteArray.usePinned { pin ->
val address = pin.addressOf(0)

SecRandomCopyBytes(
kSecRandomDefault,
byteArray.size.convert(),
address,
)
}
}
return byteArray
}
}

actual fun buildSecureRandom(): SecureRandom = IosSecureRandom()
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ internal fun ByteString.toUuid(): Uuid? {
}
}

internal fun Uuid.toBase64(): String {


fun Uuid.toBase64(): String {
val buffer = Buffer()
buffer.write(this.bytes)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.spherelabs.crypto.tinypass.database.common

import com.fleeksoft.ksoup.nodes.Document
import com.fleeksoft.ksoup.nodes.Element
import com.fleeksoft.ksoup.nodes.XmlDeclaration
import com.fleeksoft.ksoup.parser.Parser

private const val KEY_FILE = "keyfile"
private const val XML = "xml"
private const val VERSION = "version"
private const val ENCODING = "encoding"
private const val EMPTY_URI = ""

fun xml(
version: String,
charset: String,
block: (Element.() -> Element),
): Document {
return Document(EMPTY_URI).apply {
appendElement(KEY_FILE).apply {
block.invoke(this)
}
outputSettings().syntax(Document.OutputSettings.Syntax.xml)
val xmlDeclaration = XmlDeclaration(XML, false)
xmlDeclaration.attr(VERSION, version)
xmlDeclaration.attr(ENCODING, charset)
prependChild(xmlDeclaration)
}
}

fun xmlParser(
content: String,
): Document {
return Parser.xmlParser().parseInput(html = content, EMPTY_URI)
}

fun xmlParser(
content: ByteArray,
): Document {
return Parser.xmlParser().parseInput(htmlBytes = content, EMPTY_URI)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package io.spherelabs.crypto.tinypass.database.core

import io.spherelabs.crypto.cipher.AesKdf
import io.spherelabs.crypto.cipher.Argon2Engine
import io.spherelabs.crypto.cipher.Argon2Kdf
import io.spherelabs.crypto.cipher.clear
import io.spherelabs.crypto.hash.sha256
import io.spherelabs.crypto.hash.sha512
import io.spherelabs.crypto.tinypass.database.header.KeyDerivationParameters
import io.spherelabs.crypto.tinypass.database.header.OuterHeader

/**
* Creating a composite key
*
* Altogether, there seem to be three possible sources of key data: a database password, a key file
* and some abstract key provider (typically a hardware device). Key providers are assumed to implement
* a challenge-response scheme, the challenge used to produce key data being the contents of the MainSeed header field.
*
* The details of key provider implementations differ depending on the product:
* in KeePass you can only have either a key file or a key provider, in kdbxweb you can have both,
* and KeePassXC even has provisions for multiple key providers. keepass-rs on the other hand does not support key providers at all.
*
* All the various key sources are mashed together into a composite key.
* Since a database password has a wrong size, it is being hashed with SHA-256 first, resulting in 32 bytes.
* Then all key sources present are concatenated and hashed with SHA-256.
*
* In other words: if the database has only a password, its composite key will be SHA256(SHA256(password)).
* If there is also a key file, it will be SHA256(SHA256(password) + keyfile).
* If there is a key provider, its data will come instead (KeePass) or after (KeePassXC and kxdbweb) the keyfile data.
*
* https://palant.info/2023/03/29/documenting-keepass-kdbx4-file-format/
*/
fun compositeKey(key: KeyHelper): ByteArray {
val keys = listOfNotNull(
key.passphrase?.raw,
key.key?.raw,
)

val composite = when {
keys.isNotEmpty() -> {
keys.reduce { a, b -> a + b }
}
else -> ByteArray(0)
}
return composite.sha256().also { composite.clear() }
}

fun transformKey(header: OuterHeader, keys: KeyHelper): ByteArray {
return when (header.keyDerivationParameters) {
is KeyDerivationParameters.AES -> {
AesKdf.transformKey(
key = compositeKey(keys),
seed = header.keyDerivationParameters.seed.toByteArray(),
rounds = header.keyDerivationParameters.rounds,
)
}
is KeyDerivationParameters.Argon2 -> {
Argon2Kdf.transformKey(
type = when (header.keyDerivationParameters.uuid) {
KeyDerivationParameters.KdfArgon2d -> Argon2Engine.Type.Argon2D
KeyDerivationParameters.KdfArgon2id -> Argon2Engine.Type.Argon2Id
else -> throw Exception("")
},
version = Argon2Engine.Version.from(header.keyDerivationParameters.version),
password = compositeKey(keys),
salt = header.keyDerivationParameters.salt.toByteArray(),
secretKey = header.keyDerivationParameters.key?.toByteArray(),
additional = header.keyDerivationParameters.associatedData?.toByteArray(),
iterations = header.keyDerivationParameters.iterations,
parallelism = header.keyDerivationParameters.parallelism,
memory = header.keyDerivationParameters.memory,
)
}
}
}

fun masterKey(
masterSeed: ByteArray,
transformedKey: ByteArray,
) = (masterSeed + transformedKey).sha256()


fun hmacKey(
masterSeed: ByteArray,
transformedKey: ByteArray,
): ByteArray {
val combined = byteArrayOf(*masterSeed, *transformedKey, 0x01)
return (ByteArray(8) { 0xFF.toByte() } + combined.sha512())
.sha512()
.also { combined.clear() }
}

Loading

0 comments on commit 4481f03

Please # to comment.