From e99e014c4ef8ceacdafec4e8eda5cf08e137953c Mon Sep 17 00:00:00 2001 From: Vladimir Krivosheev Date: Mon, 8 Oct 2018 16:19:03 +0200 Subject: [PATCH] IDEA-200066 efficient implementation of load and save KeePass database --- .../src/KeePassCredentialStore.kt | 11 +- .../credential-store/src/credentialStore.kt | 14 +- .../src/kdbx/HashedBlockInputStream.java | 2 +- .../src/kdbx/HashedBlockOutputStream.java | 3 +- .../credential-store/src/kdbx/KdbxEntry.kt | 157 ++++++---- .../credential-store/src/kdbx/KdbxGroup.kt | 193 ++++++------- .../credential-store/src/kdbx/KdbxHeader.kt | 192 +++++++++++-- .../src/kdbx/KdbxSerializer.kt | 267 ------------------ .../src/kdbx/KeePassDatabase.kt | 97 +++++-- .../src/kdbx/ProtectedValue.kt | 107 +++++++ platform/credential-store/src/kdbx/kdbx.kt | 192 +++---------- platform/credential-store/src/kdbx/kdbxApi.kt | 25 ++ .../keePass/KeePassCredentialStoreTest.kt | 4 +- .../test/keePass/KeePassFileManagerTest.kt | 18 +- 14 files changed, 642 insertions(+), 640 deletions(-) delete mode 100644 platform/credential-store/src/kdbx/KdbxSerializer.kt create mode 100644 platform/credential-store/src/kdbx/ProtectedValue.kt create mode 100644 platform/credential-store/src/kdbx/kdbxApi.kt diff --git a/platform/credential-store/src/KeePassCredentialStore.kt b/platform/credential-store/src/KeePassCredentialStore.kt index 7a3abd87ca42..8cb9569a460d 100644 --- a/platform/credential-store/src/KeePassCredentialStore.kt +++ b/platform/credential-store/src/KeePassCredentialStore.kt @@ -171,6 +171,7 @@ internal class KeePassCredentialStore constructor(internal val dbFile: Path, val oldAttributes = toOldKey(requestor, userName) db.rootGroup.getGroup(ROOT_GROUP_NAME)?.removeEntry(oldAttributes.serviceName, oldAttributes.userName)?.let { fun createCredentials() = Credentials(userName, it.password?.get()) + @Suppress("DEPRECATION") set(CredentialAttributes(requestor, userName), createCredentials()) return createCredentials() } @@ -199,16 +200,6 @@ internal class KeePassCredentialStore constructor(internal val dbFile: Path, } } - fun copyTo(store: PasswordStorage) { - val group = db.rootGroup.getGroup(ROOT_GROUP_NAME) ?: return - for (entry in group.entries) { - val title = entry.title - if (title != null) { - store.set(CredentialAttributes(title, entry.userName), Credentials(entry.userName, entry.password?.get())) - } - } - } - /** * [MasterKey.value] will be cleared on set */ diff --git a/platform/credential-store/src/credentialStore.kt b/platform/credential-store/src/credentialStore.kt index 3eb20c63c0c7..7f10c0b80f1f 100644 --- a/platform/credential-store/src/credentialStore.kt +++ b/platform/credential-store/src/credentialStore.kt @@ -89,16 +89,24 @@ private fun parseString(data: String, delimiter: Char): List { fun Credentials.serialize(storePassword: Boolean = true) = joinData(userName, if (storePassword) password else null)!! @Suppress("FunctionName") -internal fun SecureString(value: CharSequence) = SecureString(Charsets.UTF_8.encode(CharBuffer.wrap(value)).toByteArray()) +internal fun SecureString(value: CharSequence): SecureString = SecureStringImpl(value) + +interface SecureString { + fun get(clearable: Boolean = true): OneTimeString +} + +internal class SecureStringImpl(value: ByteArray) : SecureString { + constructor(value: CharSequence) : this(Charsets.UTF_8.encode(CharBuffer.wrap(value)).toByteArray()) -internal class SecureString(value: ByteArray) { companion object { private val encryptionSupport = EncryptionSupport(SecretKeySpec(generateAesKey(), "AES")) } private val data = encryptionSupport.encrypt(value) - fun get(clearable: Boolean = true) = OneTimeString(encryptionSupport.decrypt(data), clearable = clearable) + override fun get(clearable: Boolean) = OneTimeString(getAsByteArray(), clearable = clearable) + + fun getAsByteArray() = encryptionSupport.decrypt(data) } internal val ACCESS_TO_KEY_CHAIN_DENIED = Credentials(null, null as OneTimeString?) diff --git a/platform/credential-store/src/kdbx/HashedBlockInputStream.java b/platform/credential-store/src/kdbx/HashedBlockInputStream.java index aab8f1d3ad54..705af0ba877c 100644 --- a/platform/credential-store/src/kdbx/HashedBlockInputStream.java +++ b/platform/credential-store/src/kdbx/HashedBlockInputStream.java @@ -55,7 +55,7 @@ final class HashedBlockInputStream extends InputStream { private final InputStream inputStream; private ByteArrayInputStream blockInputStream = new ByteArrayInputStream(new byte[0]); - private final MessageDigest md = KdbxHeaderKt.sha256MessageDigest(); + private final MessageDigest md = KdbxKt.sha256MessageDigest(); HashedBlockInputStream(@NotNull InputStream inputStream) { this.inputStream = inputStream; diff --git a/platform/credential-store/src/kdbx/HashedBlockOutputStream.java b/platform/credential-store/src/kdbx/HashedBlockOutputStream.java index 3badd71f5116..afaa304ebf7f 100644 --- a/platform/credential-store/src/kdbx/HashedBlockOutputStream.java +++ b/platform/credential-store/src/kdbx/HashedBlockOutputStream.java @@ -53,7 +53,7 @@ final class HashedBlockOutputStream extends OutputStream { private final ByteArrayOutputStream blockOutputStream = new ByteArrayOutputStream(); private boolean isClosed = false; - private final MessageDigest md = KdbxHeaderKt.sha256MessageDigest(); + private final MessageDigest md = KdbxKt.sha256MessageDigest(); HashedBlockOutputStream(OutputStream outputStream) { this.outputStream = outputStream; @@ -87,7 +87,6 @@ final class HashedBlockOutputStream extends OutputStream { outputStream.write(ZERO_HASH); writeInt(0); isClosed = true; - outputStream.flush(); outputStream.close(); } diff --git a/platform/credential-store/src/kdbx/KdbxEntry.kt b/platform/credential-store/src/kdbx/KdbxEntry.kt index b5c338efe269..5916ff314e35 100644 --- a/platform/credential-store/src/kdbx/KdbxEntry.kt +++ b/platform/credential-store/src/kdbx/KdbxEntry.kt @@ -16,85 +16,118 @@ package com.intellij.credentialStore.kdbx import com.intellij.credentialStore.SecureString -import com.intellij.util.element +import com.intellij.credentialStore.SecureStringImpl import com.intellij.util.getOrCreate +import com.intellij.util.text.nullize import org.jdom.Element -private const val VALUE_ELEMENT_NAME = "Value" - -internal class KdbxEntry(private val element: Element, private val database: KeePassDatabase, @Volatile internal var group: KdbxGroup?) { - @Volatile var title: String? = element.removeProperty("Title") +internal class KdbxEntry(internal val entryElement: Element, private val database: KeePassDatabase, @Volatile internal var group: KdbxGroup?) { + var title: String? + get() = getProperty(KdbxEntryElementNames.title) set(value) { - if (field != value) { - field = value - touch() - database.isDirty = true - } + setProperty(entryElement, value, KdbxEntryElementNames.title) } - @Volatile var userName: String? = element.removeProperty("UserName") + var userName: String? + get() = getProperty(KdbxEntryElementNames.userName) set(value) { - if (field != value) { - field = value - touch() - database.isDirty = true - } + setProperty(entryElement, value, KdbxEntryElementNames.userName) } - @Volatile var password: SecureString? = element.removeProperty("Password")?.let(::SecureString) - set(value) { - if (field != value) { - field = value - touch() - database.isDirty = true - } + @Synchronized + private fun getProperty(propertyName: String): String? { + val valueElement = getPropertyElement(entryElement, propertyName)?.getChild(KdbxEntryElementNames.value) + if (valueElement == null) { + return null } - fun toXml(): Element { - val element = element.clone() - element.setProperty("Title", title) - element.ensureProperty("URL") - element.setProperty("UserName", userName) - element.setProperty("Password", password?.get()?.toString()) - element.ensureProperty("Notes") - return element + val value = valueElement.text.nullize() + if (isValueProtected(valueElement)) { + throw UnsupportedOperationException("$propertyName protection is not supported") + } + else { + return value + } } + @Synchronized + private fun setProperty(entryElement: Element, value: String?, propertyName: String): Element? { + val normalizedValue = value.nullize() + var propertyElement = getPropertyElement(entryElement, propertyName) + if (propertyElement == null) { + if (normalizedValue == null) { + return null + } + + propertyElement = createPropertyElement(entryElement, propertyName) + } + + val valueElement = propertyElement.getOrCreate(KdbxEntryElementNames.value) + if (valueElement.text.nullize() == normalizedValue) { + return null + } + + valueElement.text = value + if (entryElement === this.entryElement) { + touch() + } + return valueElement + } + + var password: SecureString? + @Synchronized + get() { + val valueElement = getPropertyElement(entryElement, KdbxEntryElementNames.password)?.getChild(KdbxEntryElementNames.value) ?: return null + val value = valueElement.content.firstOrNull() ?: return null + if (value is SecureString) { + return value + } + + valueElement.setAttribute(KdbxAttributeNames.protected, "True") + val result = UnsavedProtectedValue(SecureStringImpl(value.value)) + valueElement.setContent(result) + return result + } + @Synchronized + set(value) { + if (value == null) { + val iterator = entryElement.getChildren(KdbxEntryElementNames.string).iterator() + for (element in iterator) { + if (element.getChildText(KdbxEntryElementNames.key) == KdbxEntryElementNames.password) { + iterator.remove() + touch() + } + } + return + } + + val valueElement = getOrCreatePropertyElement(KdbxEntryElementNames.password).getOrCreate(KdbxEntryElementNames.value) + valueElement.setAttribute(KdbxAttributeNames.protected, "True") + val oldValue = valueElement.content.firstOrNull() + if (oldValue === value) { + return + } + + valueElement.setContent(UnsavedProtectedValue(value as SecureStringImpl)) + touch() + } + + private fun getOrCreatePropertyElement(name: String) = getPropertyElement(entryElement, name) ?: createPropertyElement(entryElement, name) + + @Synchronized private fun touch() { - element.getOrCreate("Times").getOrCreate("LastModificationTime").text = formattedNow() + entryElement.getOrCreate("Times").getOrCreate("LastModificationTime").text = formattedNow() + database.isDirty = true } } -private fun Element.ensureProperty(name: String) { - val property = getPropertyContainer(name, false) - if (property == null) { - val container = element("String") - container.element("Key").addContent(name) - container.element("Value") - } +private fun getPropertyElement(element: Element, name: String): Element? { + return element.getChildren(KdbxEntryElementNames.string).firstOrNull { it.getChildText(KdbxEntryElementNames.key) == name } } -private fun Element.getPropertyContainer(name: String, remove: Boolean): Element? { - val iterator = getChildren("String").iterator() - for (element in iterator) { - if (element.getChildText("Key") == name) { - if (remove) { - iterator.remove() - } - return element - } - } - return null -} - -private fun Element.removeProperty(name: String) = getPropertyContainer(name, true)?.getChildText(VALUE_ELEMENT_NAME) - -private fun Element.setProperty(name: String, value: String?) { - var item = getPropertyContainer(name, false) - if (item == null) { - item = element("String") - item.element("Key").addContent(name) - } - - item.getOrCreate(VALUE_ELEMENT_NAME).text = value +private fun createPropertyElement(parentElement: Element, propertyName: String): Element { + val propertyElement = Element(KdbxEntryElementNames.string) + propertyElement.addContent(Element(KdbxEntryElementNames.key).setText(propertyName)) + parentElement.addContent(propertyElement) + return propertyElement } \ No newline at end of file diff --git a/platform/credential-store/src/kdbx/KdbxGroup.kt b/platform/credential-store/src/kdbx/KdbxGroup.kt index d876951c4630..c4a9817e980c 100644 --- a/platform/credential-store/src/kdbx/KdbxGroup.kt +++ b/platform/credential-store/src/kdbx/KdbxGroup.kt @@ -1,118 +1,117 @@ // Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. package com.intellij.credentialStore.kdbx +import com.intellij.credentialStore.LOG import com.intellij.util.containers.ContainerUtil -import com.intellij.util.containers.Stack -import com.intellij.util.get import com.intellij.util.getOrCreate -import com.intellij.util.remove +import gnu.trove.THashMap import org.jdom.Element import java.time.Instant import java.time.LocalDateTime import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeParseException -internal class KdbxGroup(private val element: Element, private val database: KeePassDatabase, @Volatile private var parent: KdbxGroup?) { - @Volatile var name: String = element.getChildText(NAME_ELEMENT_NAME) ?: "Unnamed" +internal class KdbxGroup(internal val element: Element, private val database: KeePassDatabase, @Volatile private var parent: KdbxGroup?) { + var name: String + @Synchronized + get() = element.getChildText(KdbxDbElementNames.name) ?: "Unnamed" + @Synchronized set(value) { - if (field != value) { - field = value - database.isDirty = true + val nameElement = element.getOrCreate(KdbxDbElementNames.name) + if (nameElement.text == value) { + return } - } - private val groups: MutableList - val entries: MutableList - - @Volatile - private var locationChanged = element.get("Times")?.get("LocationChanged")?.text?.let(::parseTime) ?: 0 - - init { - locationChanged = element.get("Times")?.get("LocationChanged")?.text?.let(::parseTime) ?: 0 - - groups = ContainerUtil.createLockFreeCopyOnWriteList(element.remove(GROUP_ELEMENT_NAME) { KdbxGroup(it, database, this) }) - entries = ContainerUtil.createLockFreeCopyOnWriteList(element.remove(ENTRY_ELEMENT_NAME) { KdbxEntry(it, database, this) }) - } - - fun toXml(): Element { - val element = element.clone() - element.getOrCreate(NAME_ELEMENT_NAME).text = name - - val locationChangedElement = element.getOrCreate("Times").getOrCreate("LocationChanged") - if (locationChanged == 0L) { - element.get("Times")?.get("CreationTime")?.text?.let { - locationChangedElement.text = it - } - } - else { - locationChangedElement.text = Instant.ofEpochMilli(locationChanged).atZone(ZoneOffset.UTC).format(dateFormatter) - } - - for (group in groups) { - element.addContent(group.toXml()) - } - for (entry in entries) { - element.addContent(entry.toXml()) - } - return element - } - - fun addGroup(group: KdbxGroup): KdbxGroup { - if (group == database.rootGroup) { - throw IllegalStateException("Cannot set root group as child of another group") - } - - group.parent?.removeGroup(group) - groups.add(group) - group.parent = this - group.locationChanged = LocalDateTime.now(ZoneOffset.UTC).toEpochSecond(ZoneOffset.UTC) - - database.isDirty = true - return group - } - - fun removeGroup(group: KdbxGroup): KdbxGroup { - if (groups.remove(group)) { - group.parent = null + nameElement.text = value database.isDirty = true } - return group + + private val groups: MutableMap = THashMap() + private val entries: MutableList by lazy { + ContainerUtil.createLockFreeCopyOnWriteList(element.getChildren(KdbxDbElementNames.entry).map { KdbxEntry(it, database, this) }) } + private var locationChanged: Long + get() = element.getChild("Times")?.getChild("LocationChanged")?.text?.let(::parseTime) ?: 0 + set(value) { + element.getOrCreate("Times").getOrCreate("LocationChanged").text = Instant.ofEpochMilli(value).atZone(ZoneOffset.UTC).format(dateFormatter) + } + + @Synchronized + fun getGroup(name: String): KdbxGroup? { + var result = groups.get(name) + if (result != null) { + return result + } + + val groupElement = element.content.firstOrNull { it is Element && it.getChildText(KdbxDbElementNames.name) == name } ?: return null + result = KdbxGroup(groupElement as Element, database, this) + groups.put(name, result) + return result + } + + @Synchronized + private fun removeGroup(group: KdbxGroup) { + val removedGroup = groups.remove(group.name) + LOG.assertTrue(group === removedGroup) + element.content.remove(group.element) + group.parent = null + database.isDirty = true + } + + @Synchronized fun removeGroup(name: String) { getGroup(name)?.let { removeGroup(it) } } - fun getGroup(name: String): KdbxGroup? = groups.firstOrNull { it.name == name } - - fun getOrCreateGroup(name: String): KdbxGroup = getGroup(name) ?: createGroup(name) + @Synchronized + fun getOrCreateGroup(name: String) = getGroup(name) ?: createGroup(name) private fun createGroup(name: String): KdbxGroup { val result = createGroup(database, this) result.name = name - addGroup(result) + if (result == database.rootGroup) { + throw IllegalStateException("Cannot set root group as child of another group") + } + groups.put(result.name, result) + result.parent = this + result.locationChanged = LocalDateTime.now(ZoneOffset.UTC).toEpochSecond(ZoneOffset.UTC) + element.addContent(result.element) + database.isDirty = true return result } - fun getEntry(matcher: (entry: KdbxEntry) -> Boolean): KdbxEntry? = entries.firstOrNull(matcher) + @Synchronized + fun getEntry(matcher: (entry: KdbxEntry) -> Boolean) = entries.firstOrNull(matcher) + @Synchronized fun addEntry(entry: KdbxEntry): KdbxEntry { entry.group?.removeEntry(entry) entries.add(entry) entry.group = this database.isDirty = true + element.addContent(entry.entryElement) return entry } private fun removeEntry(entry: KdbxEntry): KdbxEntry { if (entries.remove(entry)) { entry.group = null + element.content.remove(entry.entryElement) database.isDirty = true } return entry } - fun getEntry(title: String, userName: String?): KdbxEntry? = getEntry { it.title == title && (it.userName == userName || userName == null) } + @Synchronized + fun getEntry(title: String, userName: String?): KdbxEntry? { + return getEntry { + it.title == title && (it.userName == userName || userName == null) + } + } + @Synchronized fun getOrCreateEntry(title: String, userName: String?): KdbxEntry { var entry = getEntry(title, userName) if (entry == null) { @@ -123,42 +122,38 @@ internal class KdbxGroup(private val element: Element, private val database: Kee return entry } - fun removeEntry(title: String, userName: String?): KdbxEntry? = getEntry(title, userName)?.let { removeEntry(it) } - - val path: String - get() { - val parents = Stack() - var parent: KdbxGroup = this - parents.push(this) - while (true) { - parent = parent.parent ?: break - parents.push(parent) - } - val result = StringBuilder("/") - while (parents.size > 0) { - result.append(parents.pop().name).append('/') - } - return result.toString() + @Synchronized + fun removeEntry(title: String, userName: String?): KdbxEntry? { + return getEntry(title, userName)?.let { + removeEntry(it) } - - override fun toString(): String = path + } } internal fun createGroup(db: KeePassDatabase, parent: KdbxGroup?): KdbxGroup { - val element = Element(GROUP_ELEMENT_NAME) + val element = Element(KdbxDbElementNames.group) ensureElements(element, mandatoryGroupElements) return KdbxGroup(element, db, parent) } private val mandatoryGroupElements: Map, ValueCreator> = linkedMapOf( - UUID_ELEMENT_NAME to UuidValueCreator(), - arrayOf("Notes") to ConstantValueCreator(""), - ICON_ELEMENT_NAME to ConstantValueCreator("0"), - CREATION_TIME_ELEMENT_NAME to DateValueCreator(), - LAST_MODIFICATION_TIME_ELEMENT_NAME to DateValueCreator(), - LAST_ACCESS_TIME_ELEMENT_NAME to DateValueCreator(), - EXPIRY_TIME_ELEMENT_NAME to DateValueCreator(), - EXPIRES_ELEMENT_NAME to ConstantValueCreator("False"), - USAGE_COUNT_ELEMENT_NAME to ConstantValueCreator("0"), - LOCATION_CHANGED to DateValueCreator() -) \ No newline at end of file + UUID_ELEMENT_NAME to UuidValueCreator(), + arrayOf("Notes") to ConstantValueCreator(""), + ICON_ELEMENT_NAME to ConstantValueCreator("0"), + CREATION_TIME_ELEMENT_NAME to DateValueCreator(), + LAST_MODIFICATION_TIME_ELEMENT_NAME to DateValueCreator(), + LAST_ACCESS_TIME_ELEMENT_NAME to DateValueCreator(), + EXPIRY_TIME_ELEMENT_NAME to DateValueCreator(), + EXPIRES_ELEMENT_NAME to ConstantValueCreator("False"), + USAGE_COUNT_ELEMENT_NAME to ConstantValueCreator("0"), + LOCATION_CHANGED to DateValueCreator() +) + +private fun parseTime(value: String): Long { + return try { + ZonedDateTime.parse(value).toEpochSecond() + } + catch (e: DateTimeParseException) { + 0 + } +} \ No newline at end of file diff --git a/platform/credential-store/src/kdbx/KdbxHeader.kt b/platform/credential-store/src/kdbx/KdbxHeader.kt index 56f405479df7..e5033c0fb6db 100644 --- a/platform/credential-store/src/kdbx/KdbxHeader.kt +++ b/platform/credential-store/src/kdbx/KdbxHeader.kt @@ -15,6 +15,8 @@ */ package com.intellij.credentialStore.kdbx +import com.google.common.io.LittleEndianDataInputStream +import com.google.common.io.LittleEndianDataOutputStream import com.intellij.credentialStore.createSecureRandom import org.bouncycastle.crypto.engines.AESEngine import org.bouncycastle.crypto.io.CipherInputStream @@ -26,7 +28,9 @@ import org.bouncycastle.crypto.params.ParametersWithIV import java.io.InputStream import java.io.OutputStream import java.nio.ByteBuffer -import java.security.MessageDigest +import java.security.DigestInputStream +import java.security.DigestOutputStream +import java.security.SecureRandom import java.util.* /** @@ -40,6 +44,37 @@ import java.util.* */ private val AES_CIPHER = UUID.fromString("31C1F2E6-BF71-4350-BE58-05216AFC5AFF") +private const val FILE_VERSION_CRITICAL_MASK = 0xFFFF0000.toInt() + +private const val SIG1 = 0x9AA2D903.toInt() +private const val SIG2 = 0xB54BFB67.toInt() + +private const val FILE_VERSION_32 = 0x00030001 + +internal fun createProtectedStreamKey(random: SecureRandom) = random.generateSeed(32) + +private object HeaderType { + const val END: Byte = 0 + const val COMMENT: Byte = 1 + const val CIPHER_ID: Byte = 2 + const val COMPRESSION_FLAGS: Byte = 3 + const val MASTER_SEED: Byte = 4 + const val TRANSFORM_SEED: Byte = 5 + const val TRANSFORM_ROUNDS: Byte = 6 + const val ENCRYPTION_IV: Byte = 7 + const val PROTECTED_STREAM_KEY: Byte = 8 + const val STREAM_START_BYTES: Byte = 9 + const val INNER_RANDOM_STREAM_ID: Byte = 10 +} + +private fun readSignature(input: LittleEndianDataInputStream): Boolean { + return input.readInt() == SIG1 && input.readInt() == SIG2 +} + +private fun verifyFileVersion(input: LittleEndianDataInputStream): Boolean { + return input.readInt() and FILE_VERSION_CRITICAL_MASK <= FILE_VERSION_32 and FILE_VERSION_CRITICAL_MASK +} + internal class KdbxHeader { /** * The ordinal 0 represents uncompressed and 1 GZip compressed @@ -55,21 +90,19 @@ internal class KdbxHeader { NONE, ARC_FOUR, SALSA_20 } - /* the cipher in use */ - var cipherUuid = AES_CIPHER!! - private set + // the cipher in use + private var cipherUuid = AES_CIPHER /* whether the data is compressed */ var compressionFlags = CompressionFlags.GZIP private set - var masterSeed: ByteArray - var transformSeed: ByteArray - var transformRounds: Long = 6000 - var encryptionIv: ByteArray + private var masterSeed: ByteArray + private var transformSeed: ByteArray + private var transformRounds: Long = 6000 + private var encryptionIv: ByteArray var protectedStreamKey: ByteArray - var protectedStreamAlgorithm = ProtectedStreamAlgorithm.SALSA_20 - private set + private var protectedStreamAlgorithm = ProtectedStreamAlgorithm.SALSA_20 /* these bytes appear in cipher text immediately following the header */ var streamStartBytes = ByteArray(32) @@ -82,7 +115,7 @@ internal class KdbxHeader { masterSeed = random.generateSeed(32) transformSeed = random.generateSeed(32) encryptionIv = random.generateSeed(16) - protectedStreamKey = random.generateSeed(32) + protectedStreamKey = createProtectedStreamKey(random) } /** @@ -103,7 +136,7 @@ internal class KdbxHeader { return getEncryptedOutputStream(outputStream, finalKeyDigest, encryptionIv) } - fun setCipherUuid(uuid: ByteArray) { + private fun setCipherUuid(uuid: ByteArray) { val b = ByteBuffer.wrap(uuid) val incoming = UUID(b.long, b.getLong(8)) if (incoming != AES_CIPHER) { @@ -112,12 +145,115 @@ internal class KdbxHeader { cipherUuid = incoming } - fun setCompressionFlags(flags: Int) { - compressionFlags = CompressionFlags.values()[flags] + /** + * Populate a KdbxHeader from the input stream supplied + */ + internal fun readKdbxHeader(inputStream: InputStream) { + val digest = sha256MessageDigest() + // we do not close this stream, otherwise we lose our place in the underlying stream + val digestInputStream = DigestInputStream(inputStream, digest) + // we do not close this stream, otherwise we lose our place in the underlying stream + val input = LittleEndianDataInputStream(digestInputStream) + + if (!readSignature(input)) { + throw KdbxException("Bad signature") + } + + if (!verifyFileVersion(input)) { + throw IllegalStateException("File version did not match") + } + + while (true) { + val headerType = input.readByte() + if (headerType == HeaderType.END) { + break + } + + when (headerType) { + HeaderType.COMMENT -> getByteArray(input) + HeaderType.CIPHER_ID -> setCipherUuid(getByteArray(input)) + HeaderType.COMPRESSION_FLAGS -> { + compressionFlags = CompressionFlags.values()[getInt(input)] + } + HeaderType.MASTER_SEED -> masterSeed = getByteArray(input) + HeaderType.TRANSFORM_SEED -> transformSeed = getByteArray(input) + HeaderType.TRANSFORM_ROUNDS -> transformRounds = getLong(input) + HeaderType.ENCRYPTION_IV -> encryptionIv = getByteArray(input) + HeaderType.PROTECTED_STREAM_KEY -> protectedStreamKey = getByteArray(input) + HeaderType.STREAM_START_BYTES -> streamStartBytes = getByteArray(input) + HeaderType.INNER_RANDOM_STREAM_ID -> { + protectedStreamAlgorithm = ProtectedStreamAlgorithm.values()[getInt(input)] + } + + else -> throw IllegalStateException("Unknown File Header") + } + } + + // consume length etc. following END flag + getByteArray(input) + + headerHash = digest.digest() } - fun setInnerRandomStreamId(innerRandomStreamId: Int) { - protectedStreamAlgorithm = ProtectedStreamAlgorithm.values()[innerRandomStreamId] + /** + * Write a KdbxHeader to the output stream supplied. The header is updated with the + * message digest of the written stream. + */ + fun writeKdbxHeader(outputStream: OutputStream) { + val messageDigest = sha256MessageDigest() + val digestOutputStream = DigestOutputStream(outputStream, messageDigest) + val output = LittleEndianDataOutputStream(digestOutputStream) + + // write the magic number + output.writeInt(SIG1) + output.writeInt(SIG2) + // write a file version + output.writeInt(FILE_VERSION_32) + + output.writeByte(HeaderType.CIPHER_ID.toInt()) + output.writeShort(16) + val b = ByteArray(16) + val bb = ByteBuffer.wrap(b) + bb.putLong(cipherUuid.mostSignificantBits) + bb.putLong(8, cipherUuid.leastSignificantBits) + output.write(b) + + output.writeByte(HeaderType.COMPRESSION_FLAGS.toInt()) + output.writeShort(4) + output.writeInt(compressionFlags.ordinal) + + output.writeByte(HeaderType.MASTER_SEED.toInt()) + output.writeShort(masterSeed.size) + output.write(masterSeed) + + output.writeByte(HeaderType.TRANSFORM_SEED.toInt()) + output.writeShort(transformSeed.size) + output.write(transformSeed) + + output.writeByte(HeaderType.TRANSFORM_ROUNDS.toInt()) + output.writeShort(8) + output.writeLong(transformRounds) + + output.writeByte(HeaderType.ENCRYPTION_IV.toInt()) + output.writeShort(encryptionIv.size) + output.write(encryptionIv) + + output.writeByte(HeaderType.PROTECTED_STREAM_KEY.toInt()) + output.writeShort(protectedStreamKey.size) + output.write(protectedStreamKey) + + output.writeByte(HeaderType.STREAM_START_BYTES.toInt()) + output.writeShort(streamStartBytes.size) + output.write(streamStartBytes) + + output.writeByte(HeaderType.INNER_RANDOM_STREAM_ID.toInt()) + output.writeShort(4) + output.writeInt(protectedStreamAlgorithm.ordinal) + + output.writeByte(HeaderType.END.toInt()) + output.writeShort(0) + + headerHash = digestOutputStream.messageDigest.digest() } } @@ -141,8 +277,6 @@ private fun getFinalKeyDigest(key: ByteArray, masterSeed: ByteArray, transformSe return md.digest(transformedKeyDigest) } -fun sha256MessageDigest(): MessageDigest = MessageDigest.getInstance("SHA-256") - /** * Create a decrypted input stream from an encrypted one */ @@ -161,4 +295,26 @@ private fun getEncryptedOutputStream(decryptedOutputStream: OutputStream, keyDat val cipher = PaddedBufferedBlockCipher(CBCBlockCipher(AESEngine())) cipher.init(true, keyAndIV) return CipherOutputStream(decryptedOutputStream, cipher) +} + +private fun getInt(input: LittleEndianDataInputStream): Int { + val fieldLength = input.readShort() + if (fieldLength.toInt() != 4) { + throw IllegalStateException("Int required but length was $fieldLength") + } + return input.readInt() +} + +private fun getLong(input: LittleEndianDataInputStream): Long { + val fieldLength = input.readShort() + if (fieldLength.toInt() != 8) { + throw IllegalStateException("Long required but length was $fieldLength") + } + return input.readLong() +} + +private fun getByteArray(input: LittleEndianDataInputStream): ByteArray { + val value = ByteArray(input.readShort().toInt()) + input.readFully(value) + return value } \ No newline at end of file diff --git a/platform/credential-store/src/kdbx/KdbxSerializer.kt b/platform/credential-store/src/kdbx/KdbxSerializer.kt deleted file mode 100644 index 5fd795460ca6..000000000000 --- a/platform/credential-store/src/kdbx/KdbxSerializer.kt +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Copyright 2015 Jo Rabin - * - * 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 com.intellij.credentialStore.kdbx - -import com.google.common.io.LittleEndianDataInputStream -import com.google.common.io.LittleEndianDataOutputStream -import java.io.InputStream -import java.io.OutputStream -import java.nio.ByteBuffer -import java.security.DigestInputStream -import java.security.DigestOutputStream -import java.util.* -import java.util.zip.GZIPInputStream -import java.util.zip.GZIPOutputStream - -/** - * A KDBX file is little-endian and consists of the following: - * - * 1. An unencrypted portion - * - * 1. 8 bytes Magic number - * 1. 4 bytes version - * 1. A header containing details of the encryption of the remainder of the file - * - * The header fields are encoded using a TLV style. The Type is an enumeration encoded in 1 byte. - * The length is encoded in 2 bytes and the value according to the length denoted. The sequence is - * terminated by a zero type with 0 length. - * - * 1. An encrypted portion - * - * 1. A sequence of bytes contained in the header. If they don't match, decryption has not worked. - * 1. A payload serialized in Hashed Block format. - * - * The methods in this class provide support for serializing and deserializing plain text payload content - * to and from the above format. - * @author jo - */ -internal object KdbxSerializer { - /** - * Provides the payload of a KDBX file as an unencrypted [InputStream]. - - * @param credentials credentials for decryption of the stream - * @param kdbxHeader a header instance to be populated with values from the stream - * @param inputStream a KDBX formatted input stream - * @return an unencrypted input stream, to be read and closed by the caller - */ - fun createUnencryptedInputStream(credentials: KeePassCredentials, kdbxHeader: KdbxHeader, inputStream: InputStream): InputStream { - readKdbxHeader(kdbxHeader, inputStream) - - val decryptedInputStream = kdbxHeader.createDecryptedStream(credentials.key, inputStream) - checkStartBytes(kdbxHeader, decryptedInputStream) - val blockInputStream = HashedBlockInputStream(decryptedInputStream) - if (kdbxHeader.compressionFlags == KdbxHeader.CompressionFlags.NONE) { - return blockInputStream - } - return GZIPInputStream(blockInputStream) - } - - /** - * Provides an [OutputStream] to be encoded and encrypted in KDBX format - * @param credentials credentials for encryption of the stream - * @param kdbxHeader a KDBX header to control the formatting and encryption operation - * @param outputStream output stream to contain the KDBX formatted output - * @return an unencrypted output stream, to be written to, flushed and closed by the caller - */ - fun createEncryptedOutputStream(credentials: KeePassCredentials, kdbxHeader: KdbxHeader, outputStream: OutputStream): OutputStream { - writeKdbxHeader(kdbxHeader, outputStream) - - val encryptedOutputStream = kdbxHeader.createEncryptedStream(credentials.key, outputStream) - LittleEndianDataOutputStream(encryptedOutputStream).write(kdbxHeader.streamStartBytes) - - val blockOutputStream = HashedBlockOutputStream(encryptedOutputStream) - if (kdbxHeader.compressionFlags == KdbxHeader.CompressionFlags.NONE) { - return blockOutputStream - } - return GZIPOutputStream(blockOutputStream) - } -} - -private fun checkStartBytes(kdbxHeader: KdbxHeader, decryptedInputStream: InputStream) { - val startBytes = ByteArray(32) - LittleEndianDataInputStream(decryptedInputStream).readFully(startBytes) - if (!Arrays.equals(startBytes, kdbxHeader.streamStartBytes)) { - throw IncorrectMasterPasswordException() - } -} - -internal class IncorrectMasterPasswordException(val isFileMissed: Boolean = false) : RuntimeException() - -private const val SIG1 = 0x9AA2D903.toInt() -private const val SIG2 = 0xB54BFB67.toInt() -private const val FILE_VERSION_CRITICAL_MASK = 0xFFFF0000.toInt() -private const val FILE_VERSION_32 = 0x00030001 - -private object HeaderType { - internal const val END: Byte = 0 - internal const val COMMENT: Byte = 1 - internal const val CIPHER_ID: Byte = 2 - internal const val COMPRESSION_FLAGS: Byte = 3 - internal const val MASTER_SEED: Byte = 4 - internal const val TRANSFORM_SEED: Byte = 5 - internal const val TRANSFORM_ROUNDS: Byte = 6 - internal const val ENCRYPTION_IV: Byte = 7 - internal const val PROTECTED_STREAM_KEY: Byte = 8 - internal const val STREAM_START_BYTES: Byte = 9 - internal const val INNER_RANDOM_STREAM_ID: Byte = 10 -} - -private fun verifyMagicNumber(input: LittleEndianDataInputStream): Boolean { - val sig1 = input.readInt() - val sig2 = input.readInt() - return sig1 == SIG1 && sig2 == SIG2 -} - -private fun verifyFileVersion(input: LittleEndianDataInputStream): Boolean { - return input.readInt() and FILE_VERSION_CRITICAL_MASK <= FILE_VERSION_32 and FILE_VERSION_CRITICAL_MASK -} - -/** - * Populate a KdbxHeader from the input stream supplied - - * @param kdbxHeader a header to be populated - * @param inputStream an input stream - * @return the populated KdbxHeader - */ -internal fun readKdbxHeader(kdbxHeader: KdbxHeader, inputStream: InputStream): KdbxHeader { - val digest = sha256MessageDigest() - // we do not close this stream, otherwise we lose our place in the underlying stream - val digestInputStream = DigestInputStream(inputStream, digest) - // we do not close this stream, otherwise we lose our place in the underlying stream - val input = LittleEndianDataInputStream(digestInputStream) - - if (!verifyMagicNumber(input)) { - throw IllegalStateException("Magic number did not match") - } - - if (!verifyFileVersion(input)) { - throw IllegalStateException("File version did not match") - } - - while (true) { - val headerType = input.readByte() - if (headerType == HeaderType.END) { - break - } - - when (headerType) { - HeaderType.COMMENT -> getByteArray(input) - HeaderType.CIPHER_ID -> kdbxHeader.setCipherUuid(getByteArray(input)) - HeaderType.COMPRESSION_FLAGS -> kdbxHeader.setCompressionFlags(getInt(input)) - HeaderType.MASTER_SEED -> kdbxHeader.masterSeed = getByteArray(input) - HeaderType.TRANSFORM_SEED -> kdbxHeader.transformSeed = getByteArray(input) - HeaderType.TRANSFORM_ROUNDS -> kdbxHeader.transformRounds = getLong(input) - HeaderType.ENCRYPTION_IV -> kdbxHeader.encryptionIv = getByteArray(input) - HeaderType.PROTECTED_STREAM_KEY -> kdbxHeader.protectedStreamKey = getByteArray(input) - HeaderType.STREAM_START_BYTES -> kdbxHeader.streamStartBytes = getByteArray(input) - HeaderType.INNER_RANDOM_STREAM_ID -> kdbxHeader.setInnerRandomStreamId(getInt(input)) - - else -> throw IllegalStateException("Unknown File Header") - } - } - - // consume length etc. following END flag - getByteArray(input) - - kdbxHeader.headerHash = digest.digest() - return kdbxHeader -} - -/** - * Write a KdbxHeader to the output stream supplied. The header is updated with the - * message digest of the written stream. - - * @param kdbxHeader the header to write and update - * @param outputStream the output stream - */ -internal fun writeKdbxHeader(kdbxHeader: KdbxHeader, outputStream: OutputStream) { - val messageDigest = sha256MessageDigest() - val digestOutputStream = DigestOutputStream(outputStream, messageDigest) - val output = LittleEndianDataOutputStream(digestOutputStream) - - // write the magic number - output.writeInt(SIG1) - output.writeInt(SIG2) - // write a file version - output.writeInt(FILE_VERSION_32) - - output.writeByte(HeaderType.CIPHER_ID.toInt()) - output.writeShort(16) - val b = ByteArray(16) - val bb = ByteBuffer.wrap(b) - bb.putLong(kdbxHeader.cipherUuid.mostSignificantBits) - bb.putLong(8, kdbxHeader.cipherUuid.leastSignificantBits) - output.write(b) - - output.writeByte(HeaderType.COMPRESSION_FLAGS.toInt()) - output.writeShort(4) - output.writeInt(kdbxHeader.compressionFlags.ordinal) - - output.writeByte(HeaderType.MASTER_SEED.toInt()) - output.writeShort(kdbxHeader.masterSeed.size) - output.write(kdbxHeader.masterSeed) - - output.writeByte(HeaderType.TRANSFORM_SEED.toInt()) - output.writeShort(kdbxHeader.transformSeed.size) - output.write(kdbxHeader.transformSeed) - - output.writeByte(HeaderType.TRANSFORM_ROUNDS.toInt()) - output.writeShort(8) - output.writeLong(kdbxHeader.transformRounds) - - output.writeByte(HeaderType.ENCRYPTION_IV.toInt()) - output.writeShort(kdbxHeader.encryptionIv.size) - output.write(kdbxHeader.encryptionIv) - - output.writeByte(HeaderType.PROTECTED_STREAM_KEY.toInt()) - output.writeShort(kdbxHeader.protectedStreamKey.size) - output.write(kdbxHeader.protectedStreamKey) - - output.writeByte(HeaderType.STREAM_START_BYTES.toInt()) - output.writeShort(kdbxHeader.streamStartBytes.size) - output.write(kdbxHeader.streamStartBytes) - - output.writeByte(HeaderType.INNER_RANDOM_STREAM_ID.toInt()) - output.writeShort(4) - output.writeInt(kdbxHeader.protectedStreamAlgorithm.ordinal) - - output.writeByte(HeaderType.END.toInt()) - output.writeShort(0) - - kdbxHeader.headerHash = digestOutputStream.messageDigest.digest() -} - -private fun getInt(input: LittleEndianDataInputStream): Int { - val fieldLength = input.readShort() - if (fieldLength.toInt() != 4) { - throw IllegalStateException("Int required but length was $fieldLength") - } - return input.readInt() -} - -private fun getLong(input: LittleEndianDataInputStream): Long { - val fieldLength = input.readShort() - if (fieldLength.toInt() != 8) { - throw IllegalStateException("Long required but length was $fieldLength") - } - return input.readLong() -} - -private fun getByteArray(input: LittleEndianDataInputStream): ByteArray { - val value = ByteArray(input.readShort().toInt()) - input.readFully(value) - return value -} diff --git a/platform/credential-store/src/kdbx/KeePassDatabase.kt b/platform/credential-store/src/kdbx/KeePassDatabase.kt index 9d586233b6b2..a7a405b07c7a 100644 --- a/platform/credential-store/src/kdbx/KeePassDatabase.kt +++ b/platform/credential-store/src/kdbx/KeePassDatabase.kt @@ -1,8 +1,9 @@ // Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. package com.intellij.credentialStore.kdbx -import com.intellij.util.get +import com.google.common.io.LittleEndianDataOutputStream import com.intellij.util.getOrCreate +import com.intellij.util.loadElement import org.jdom.Element import java.io.OutputStream import java.nio.ByteBuffer @@ -10,10 +11,16 @@ import java.time.LocalDateTime import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.util.* +import java.util.zip.GZIPOutputStream -internal const val ENTRY_ELEMENT_NAME = "Entry" -internal const val GROUP_ELEMENT_NAME = "Group" -internal const val NAME_ELEMENT_NAME = "Name" +internal object KdbxDbElementNames { + const val group = "Group" + const val entry = "Entry" + + const val root = "Root" + + const val name = "Name" +} internal val LOCATION_CHANGED = arrayOf("Times", "LocationChanged") internal val USAGE_COUNT_ELEMENT_NAME = arrayOf("Times", "UsageCount") @@ -25,10 +32,10 @@ internal val CREATION_TIME_ELEMENT_NAME = arrayOf("Times", "CreationTime") internal val LAST_ACCESS_TIME_ELEMENT_NAME = arrayOf("Times", "LastAccessTime") internal val EXPIRY_TIME_ELEMENT_NAME = arrayOf("Times", "ExpiryTime") -private const val ROOT_ELEMENT_NAME = "Root" - internal var dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") +// we should on each save change protectedStreamKey for security reasons (as KeeWeb also does) +// so, this requirement (is it really required?) can force us to re-encrypt all passwords on save internal class KeePassDatabase(private val rootElement: Element = createEmptyDatabase()) { @Volatile var isDirty: Boolean = false @@ -37,31 +44,44 @@ internal class KeePassDatabase(private val rootElement: Element = createEmptyDat val rootGroup: KdbxGroup init { - val rootElement = rootElement.get(ROOT_ELEMENT_NAME) - val groupElement = rootElement?.get("Group") + val rootElement = rootElement.getOrCreate(KdbxDbElementNames.root) + val groupElement = rootElement.getChild(KdbxDbElementNames.group) if (groupElement == null) { rootGroup = createGroup(this, null) - rootGroup.name = ROOT_ELEMENT_NAME + rootGroup.name = KdbxDbElementNames.root + rootElement.addContent(rootGroup.element) } else { - rootElement.removeChild("Group") rootGroup = KdbxGroup(groupElement, this, null) } } + @Synchronized fun save(credentials: KeePassCredentials, outputStream: OutputStream) { - val element = rootElement.clone() - element.getOrCreate(ROOT_ELEMENT_NAME).addContent(rootGroup.toXml()) val kdbxHeader = KdbxHeader() - KdbxSerializer.createEncryptedOutputStream(credentials, kdbxHeader, outputStream).use { - element.getOrCreate("HeaderHash").text = Base64.getEncoder().encodeToString(kdbxHeader.headerHash) - save(element, it, Salsa20Encryption(kdbxHeader.protectedStreamKey)) + kdbxHeader.writeKdbxHeader(outputStream) + + val metaElement = rootElement.getOrCreate("Meta") + metaElement.getOrCreate("HeaderHash").text = Base64.getEncoder().encodeToString(kdbxHeader.headerHash) + metaElement.getOrCreate("MemoryProtection").getOrCreate("ProtectPassword").text = "True" + + val encryptedOutputStream = kdbxHeader.createEncryptedStream(credentials.key, outputStream) + LittleEndianDataOutputStream(encryptedOutputStream).write(kdbxHeader.streamStartBytes) + + var kdbxOutput: OutputStream = HashedBlockOutputStream(encryptedOutputStream) + if (kdbxHeader.compressionFlags == KdbxHeader.CompressionFlags.GZIP) { + kdbxOutput = GZIPOutputStream(kdbxOutput, 8 * 1024) } + + kdbxOutput.writer().use { + ProtectedXmlWriter(createSalsa20StreamCipher(kdbxHeader.protectedStreamKey)).printElement(it, rootElement, 0) + } + isDirty = false } fun createEntry(title: String): KdbxEntry { - val element = Element(ENTRY_ELEMENT_NAME) + val element = Element(KdbxDbElementNames.entry) ensureElements(element, mandatoryEntryElements) val result = KdbxEntry(element, this, null) @@ -118,14 +138,53 @@ internal class DateValueCreator : ValueCreator { internal class UuidValueCreator : ValueCreator { override val value: String - get() = base64RandomUuid() + get() = base64FromUuid(UUID.randomUUID()) } -internal fun base64RandomUuid() = base64FromUuid(UUID.randomUUID()) - private fun base64FromUuid(uuid: UUID): String { val b = ByteBuffer.wrap(ByteArray(16)) b.putLong(uuid.mostSignificantBits) b.putLong(uuid.leastSignificantBits) return Base64.getEncoder().encodeToString(b.array()) +} + +private fun createEmptyDatabase(): Element { + val creationDate = formattedNow() + @Suppress("SpellCheckingInspection") + return loadElement(""" + + IJ + + New Database + ${creationDate} + Empty Database + ${creationDate} + + ${creationDate} + 365 + + ${creationDate} + -1 + -1 + + False + False + True + False + False + + + True + AAAAAAAAAAAAAAAAAAAAAA== + ${creationDate} + AAAAAAAAAAAAAAAAAAAAAA== + ${creationDate} + AAAAAAAAAAAAAAAAAAAAAA== + AAAAAAAAAAAAAAAAAAAAAA== + 10 + 6291456 + + + + """) } \ No newline at end of file diff --git a/platform/credential-store/src/kdbx/ProtectedValue.kt b/platform/credential-store/src/kdbx/ProtectedValue.kt new file mode 100644 index 000000000000..22cf3f229297 --- /dev/null +++ b/platform/credential-store/src/kdbx/ProtectedValue.kt @@ -0,0 +1,107 @@ +// Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package com.intellij.credentialStore.kdbx + +import com.intellij.configurationStore.JbXmlOutputter +import com.intellij.credentialStore.OneTimeString +import com.intellij.credentialStore.SecureString +import com.intellij.credentialStore.SecureStringImpl +import org.bouncycastle.crypto.SkippingStreamCipher +import org.jdom.Element +import org.jdom.Text +import java.io.Writer +import java.util.* + +internal class ProtectedValue(private var encryptedValue: ByteArray, private var position: Int, private var streamCipher: SkippingStreamCipher) : Text(), SecureString { + @Synchronized + override fun get(clearable: Boolean): OneTimeString { + val output = ByteArray(encryptedValue.size) + decryptInto(output) + return OneTimeString(output, clearable = clearable) + } + + @Synchronized + fun setNewStreamCipher(newStreamCipher: SkippingStreamCipher) { + val value = encryptedValue + decryptInto(value) + + position = newStreamCipher.position.toInt() + newStreamCipher.processBytes(value, 0, value.size, value, 0) + streamCipher = newStreamCipher + } + + @Synchronized + private fun decryptInto(out: ByteArray) { + streamCipher.seekTo(position.toLong()) + streamCipher.processBytes(encryptedValue, 0, encryptedValue.size, out, 0) + } + + override fun getText() = throw IllegalStateException("encodeToBase64 must be used for serialization") + + fun encodeToBase64(): String { + return when { + encryptedValue.isEmpty() -> "" + else -> Base64.getEncoder().encodeToString(encryptedValue) + } + } +} + +internal class UnsavedProtectedValue(val secureString: SecureStringImpl) : Text(), SecureString by secureString { + override fun getText() = throw IllegalStateException("Must be converted to ProtectedValue for serialization") +} + +internal class ProtectedXmlWriter(private val streamCipher: SkippingStreamCipher) : JbXmlOutputter("\n", null, null, null) { + override fun writeContent(out: Writer, element: Element, level: Int): Boolean { + if (element.name == KdbxEntryElementNames.value) { + val value = element.content.firstOrNull() + if (value is SecureString) { + val protectedValue: ProtectedValue + if (value is ProtectedValue) { + value.setNewStreamCipher(streamCipher) + protectedValue = value + } + else { + val bytes = (value as UnsavedProtectedValue).secureString.getAsByteArray() + val position = streamCipher.position.toInt() + streamCipher.processBytes(bytes, 0, bytes.size, bytes, 0) + protectedValue = ProtectedValue(bytes, position, streamCipher) + element.setContent(protectedValue) + } + + out.write('>'.toInt()) + out.write(escapeElementEntities(protectedValue.encodeToBase64())) + return true + } + } + + return super.writeContent(out, element, level) + } +} + +internal fun isValueProtected(valueElement: Element) = valueElement.getAttributeValue(KdbxAttributeNames.protected).equals("true", ignoreCase = true) + +internal class XmlProtectedValueTransformer(private val streamCipher: SkippingStreamCipher) { + private var position = 0 + + fun processEntries(parentElement: Element) { + // we must process in exact order + for (element in parentElement.content) { + if (element !is Element) { + continue + } + + if (element.name == KdbxDbElementNames.group) { + processEntries(element) + } + else if (element.name == KdbxDbElementNames.entry) { + for (container in element.getChildren(KdbxEntryElementNames.string)) { + val valueElement = container.getChild(KdbxEntryElementNames.value) ?: continue + if (isValueProtected(valueElement)) { + val value = Base64.getDecoder().decode(valueElement.text) + valueElement.setContent(ProtectedValue(value, position, streamCipher)) + position += value.size + } + } + } + } + } +} \ No newline at end of file diff --git a/platform/credential-store/src/kdbx/kdbx.kt b/platform/credential-store/src/kdbx/kdbx.kt index 5a602f33f149..80ad0b2edb47 100644 --- a/platform/credential-store/src/kdbx/kdbx.kt +++ b/platform/credential-store/src/kdbx/kdbx.kt @@ -1,181 +1,65 @@ // Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. package com.intellij.credentialStore.kdbx +import com.google.common.io.LittleEndianDataInputStream import com.intellij.openapi.util.JDOMUtil -import com.intellij.util.SmartList import com.intellij.util.io.inputStream -import com.intellij.util.loadElement -import com.intellij.util.write +import org.bouncycastle.crypto.SkippingStreamCipher import org.bouncycastle.crypto.engines.Salsa20Engine import org.bouncycastle.crypto.params.KeyParameter import org.bouncycastle.crypto.params.ParametersWithIV -import org.jdom.Element import java.io.InputStream -import java.io.OutputStream import java.nio.file.Path import java.security.MessageDigest -import java.time.ZonedDateTime -import java.time.format.DateTimeParseException import java.util.* +import java.util.zip.GZIPInputStream + +// https://gist.github.com/lgg/e6ccc6e212d18dd2ecd8a8c116fb1e45 @Throws(IncorrectMasterPasswordException::class) internal fun loadKdbx(file: Path, credentials: KeePassCredentials): KeePassDatabase { - return file.inputStream().use { inputStream -> - val kdbxHeader = KdbxHeader() - val element = KdbxSerializer.createUnencryptedInputStream(credentials, kdbxHeader, inputStream).use { - load(it, Salsa20Encryption(kdbxHeader.protectedStreamKey)) - } - KeePassDatabase(element) - } + return file.inputStream().buffered().use { readKeePassDatabase(credentials, it) } } -class KdbxPassword(password: ByteArray) : KeePassCredentials { +private fun readKeePassDatabase(credentials: KeePassCredentials, inputStream: InputStream): KeePassDatabase { + val kdbxHeader = KdbxHeader() + kdbxHeader.readKdbxHeader(inputStream) + val decryptedInputStream = kdbxHeader.createDecryptedStream(credentials.key, inputStream) + + val startBytes = ByteArray(32) + LittleEndianDataInputStream(decryptedInputStream).readFully(startBytes) + if (!Arrays.equals(startBytes, kdbxHeader.streamStartBytes)) { + throw IncorrectMasterPasswordException() + } + + var resultInputStream: InputStream = HashedBlockInputStream(decryptedInputStream) + if (kdbxHeader.compressionFlags == KdbxHeader.CompressionFlags.GZIP) { + resultInputStream = GZIPInputStream(resultInputStream) + } + val element = JDOMUtil.load(resultInputStream) + element.getChild(KdbxDbElementNames.root)?.let { rootElement -> + XmlProtectedValueTransformer(createSalsa20StreamCipher(kdbxHeader.protectedStreamKey)).processEntries(rootElement) + } + return KeePassDatabase(element) +} + +internal class KdbxPassword(password: ByteArray) : KeePassCredentials { override val key: ByteArray init { - val md = MessageDigest.getInstance("SHA-256") + val md = sha256MessageDigest() key = md.digest(md.digest(password)) } } -interface KeePassCredentials { - val key: ByteArray -} +internal fun sha256MessageDigest() = MessageDigest.getInstance("SHA-256") -internal fun save(rootElement: Element, outputStream: OutputStream, encryption: KdbxEncryption) { - val meta = rootElement.getChild("Meta")?.getChild("MemoryProtection") - if (meta != null) { - val propertiesToProtect = SmartList() - for (element in meta.children) { - val propertyName = element.name.removePrefix("Protect") - if (propertyName != element.name && element.text.equals("true", ignoreCase = true)) { - propertiesToProtect.add(propertyName) - } - } +// 0xE830094B97205D2A +private val SALSA20_IV = byteArrayOf(-24, 48, 9, 75, -105, 32, 93, 42) - rootElement.getChild("Root")?.getChild("Group")?.let { rootGroupElement -> - processEntries(rootGroupElement) { container, valueElement -> - val key = container.getChildText("Key") ?: return@processEntries - for (propertyName in propertiesToProtect) { - if (key == propertyName) { - valueElement.setAttribute("Protected", "True") - valueElement.text = Base64.getEncoder().encodeToString(encryption.encrypt(valueElement.text.toByteArray())) - } - } - } - } - } - rootElement.write(outputStream) -} - -private fun load(inputStream: InputStream, encryption: KdbxEncryption): Element { - val rootElement = JDOMUtil.load(inputStream) - rootElement.getChild("Root")?.getChild("Group")?.let { rootGroupElement -> - processEntries(rootGroupElement) { _, valueElement -> - if (valueElement.getAttributeValue("Protected", "false").equals("true", ignoreCase = true)) { - valueElement.text = encryption.decrypt(Base64.getDecoder().decode(valueElement.text)).toString(Charsets.UTF_8) - valueElement.removeAttribute("Protected") - } - } - } - return rootElement -} - -private fun processEntries(groupElement: Element, processor: (container: Element, valueElement: Element) -> Unit) { - // we must process in exact order - for (element in groupElement.children) { - if (element.name == GROUP_ELEMENT_NAME) { - processEntries(element, processor) - } - else if (element.name == ENTRY_ELEMENT_NAME) { - for (container in element.getChildren("String")) { - val valueElement = container.getChild("Value") ?: continue - processor(container, valueElement) - } - } - } -} - -internal fun createEmptyDatabase(): Element { - val creationDate = formattedNow() - return loadElement(""" - - IJ - - New Database - ${creationDate} - Empty Database - ${creationDate} - - ${creationDate} - 365 - - ${creationDate} - -1 - -1 - - False - False - True - False - False - - - True - AAAAAAAAAAAAAAAAAAAAAA== - ${creationDate} - AAAAAAAAAAAAAAAAAAAAAA== - ${creationDate} - AAAAAAAAAAAAAAAAAAAAAA== - AAAAAAAAAAAAAAAAAAAAAA== - 10 - 6291456 - - - - """) -} - -internal interface KdbxEncryption { - val key: ByteArray - - fun decrypt(encryptedText: ByteArray): ByteArray - - fun encrypt(decryptedText: ByteArray): ByteArray -} - -private val SALSA20_IV = byteArrayOf(-24, 48, 9, 75, -105, 32, 93, 42) // 0xE830094B97205D2A - -/** - * Salsa20 doesn't quite fit the KeePass memory model - all encrypted items have to be en/decrypted in order of encryption, - * i.e. in document order and at the same time. - */ -internal class Salsa20Encryption(override val key: ByteArray) : KdbxEncryption { - private val salsa20 = Salsa20Engine() - - init { - val keyParameter = KeyParameter(sha256MessageDigest().digest(key)) - salsa20.init(true, ParametersWithIV(keyParameter, SALSA20_IV)) - } - - override fun decrypt(encryptedText: ByteArray): ByteArray { - val output = ByteArray(encryptedText.size) - salsa20.processBytes(encryptedText, 0, encryptedText.size, output, 0) - return output - } - - override fun encrypt(decryptedText: ByteArray): ByteArray { - val output = ByteArray(decryptedText.size) - salsa20.processBytes(decryptedText, 0, decryptedText.size, output, 0) - return output - } -} - -internal fun parseTime(value: String): Long { - return try { - ZonedDateTime.parse(value).toEpochSecond() - } - catch (e: DateTimeParseException) { - 0 - } +internal fun createSalsa20StreamCipher(key: ByteArray): SkippingStreamCipher { + val streamCipher = Salsa20Engine() + val keyParameter = KeyParameter(sha256MessageDigest().digest(key)) + streamCipher.init(true /* doesn't matter, Salsa20 encryption and decryption is completely symmetrical */, ParametersWithIV(keyParameter, SALSA20_IV)) + return streamCipher } \ No newline at end of file diff --git a/platform/credential-store/src/kdbx/kdbxApi.kt b/platform/credential-store/src/kdbx/kdbxApi.kt new file mode 100644 index 000000000000..f3b005a3d660 --- /dev/null +++ b/platform/credential-store/src/kdbx/kdbxApi.kt @@ -0,0 +1,25 @@ +// Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package com.intellij.credentialStore.kdbx + +internal object KdbxAttributeNames { + const val protected = "Protected" +} + +internal object KdbxEntryElementNames { + const val title = "Title" + const val userName = "UserName" + const val password = "Password" + + const val value = "Value" + const val key = "Key" + + const val string = "String" +} + +internal class IncorrectMasterPasswordException(val isFileMissed: Boolean = false) : RuntimeException() + +internal interface KeePassCredentials { + val key: ByteArray +} + +internal class KdbxException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/platform/credential-store/test/keePass/KeePassCredentialStoreTest.kt b/platform/credential-store/test/keePass/KeePassCredentialStoreTest.kt index 5b8971c70d1b..26c58e25cd05 100644 --- a/platform/credential-store/test/keePass/KeePassCredentialStoreTest.kt +++ b/platform/credential-store/test/keePass/KeePassCredentialStoreTest.kt @@ -145,10 +145,10 @@ class KeePassCredentialStoreTest { assertThat(pdbFile).doesNotExist() assertThat(pdbPwdFile).doesNotExist() } - - private fun randomString() = UUID.randomUUID().toString() } +private fun randomString() = UUID.randomUUID().toString() + // avoid this constructor in production sources to avoid m @Suppress("TestFunctionName") internal fun createStore(baseDir: Path): KeePassCredentialStore { diff --git a/platform/credential-store/test/keePass/KeePassFileManagerTest.kt b/platform/credential-store/test/keePass/KeePassFileManagerTest.kt index 93814f32d264..e433fdb4a1ad 100644 --- a/platform/credential-store/test/keePass/KeePassFileManagerTest.kt +++ b/platform/credential-store/test/keePass/KeePassFileManagerTest.kt @@ -9,12 +9,12 @@ import com.intellij.credentialStore.kdbx.KeePassDatabase import com.intellij.credentialStore.kdbx.loadKdbx import com.intellij.testFramework.assertions.Assertions.assertThat import com.intellij.testFramework.rules.InMemoryFsRule -import com.intellij.util.Base64 import com.intellij.util.io.* import org.junit.Rule import org.junit.Test import java.awt.Component import java.nio.file.Path +import java.util.* private val testCredentialAttributes = CredentialAttributes("foo", "u") @@ -138,7 +138,7 @@ internal class KeePassFileManagerTest { // assert that other store not corrupted fsRule.fs.getPath("/other/otherKey").move(fsRule.fs.getPath("/other/${MASTER_KEY_FILE_NAME}")) otherStore.reload() - assertThat(otherStore.get(testCredentialAttributes)!!.getPasswordAsString()).isEqualTo("p") + assertThat(otherStore.get(testCredentialAttributes)!!.password!!.toString()).isEqualTo("p") } @Test @@ -158,7 +158,19 @@ internal class KeePassFileManagerTest { fun reuseExisting() { val dbFile = fsRule.fs.getPath("/existingDb.kdbx") @Suppress("SpellCheckingInspection") - dbFile.write(Base64.decode("A9mimmf7S7UBAAMAAhAAMcHy5r9xQ1C+WAUhavxa/wMEAAEAAAAEIAAWuDmigh57N8BZgj1w0GctkaqFTO7nPOroO5AnmUliFgUgAHCRnuiXHvLqSv2oJPMHI5QS9Ony+oIOww4kHcpz2wmEBggAcBcAAAAAAAAHEAB2bInsMKh/zNzzBaegBa/kCCAAw3NSQcu+oGXwJvY9Ht6NSCC19uGFN+sEP9n9E3tNCvoJIADD/kYwUqnCSzrzYXu3tcJcvBKzTinwcSZQ769wYZ/oPwoEAAIAAAAABAAA0K0KdKTDfpMiRxKSe1xkvCDuBDdlDe/hiU3YnkhhCZk48sB9OV4uZI1LPJAgQ8HogjxMjAhnNThYdjlFQ9TTfsb3wUXH/7IK8n69a8IoWuRp9fnchQFnnArXcZeimeUREb/3jcwTXrIHGg+AN7MPQCD/b47us2h07oaAVvGFLCaLU111mJNZrdjNvO9BKODUbqfFFVhJhMAazjsdVpRfV3dbm0apdgluJQ8eUFzrF4YZUUkenG869o/yngD2cm1BxmG2QZe2fel/PSWRRFZ0i6zJm3h9CnYStnDL2N6lm9MPqOm3pKUk0uOG8p9P4U4UHMOnwIFw4RReO5wnqpMN4DFUCfM1qqx0fuqc/sy3DEgpRg3ENsHU7AbidobJzIqGOeK9ywU6Rp5peJrLjbIszedVWVPvxwt/xgAHcaqfntV86XcG8MlJElLSsK4fh9gspySNeyP43wnLjAdFGHq5OtAkhTTwXzHSZTxhazTjClssAHYAyTEQrlYRFl4+4apzdq5g3crGl1vR0Ekj22ytIsQXu9HYVTZ6pk6ESQAIg1qieDZQl4B1eEaRNnyvkfhgPIUDLuG4ULMGm/L1dur2nhHlooqgR8gHo5MNclghOKZdOhJNOvsd7/1XDsziizOqbocjJtBCEOWSgI1Ht1fbmG1nO9C6MrzkkxK+xQaXZDG2fmitUw2flN3OMky/RJgMD4LVdZLoaHj3uq86USXjDc0ql7lHeRRmOhIJ3X8DZObJmyhlylBGYwmYB/PmOuoe28iM8/wQ4Xr8bSYco5kxOK8Bii5TotDXqhVlTdajCo5NN33nqeSc/f+5BGL/CmdxannLel1bnEJrY2ESYXrjzUSM0pgnJlLNxZYynw/XMtSPbXI8/m2ciAP9iXv5efJMOv8O6dmzlbiYz7efSEpIDiwHG7HAIVnVzmy26jhyHXchaQpouPnwj/QhzTL1lRv0qA2K5+wNIexkKQa4G4iZhSHVzZTHY12rpnLDTxWv2GXHCvwdY5AD6jTJazimlTpbVOf+UvDIPiY2ksmhnaZU9Lc/ItkInhtZwW0e0XtdcgunaVr5BvHTRRancrtRN23VAUlNzQ7Uror55JG3PxUThOX8XRmqJxMnIlgpNbv/tgqbks7zuCESLFjz0EM19QygatS+uHCWnsUp8sWl14bcrwGRoFsdPj/AFRGAv2xYwfMT8VBOZx1KpQ0vqxOx8t67pxpBUaH/Cqlh8Jje7vxXT7wrXQK3bVjpk2uYncAkd7ruk0y7X/Jzvyu9cfoEDn6EMOS0b5aE6VoywNHhbo3dGyZq3K25jOGvgLzRSj9ETBNL8DTccaNzkcyXBsj/gqUZ1rYxVZHPaJWi2Cgy07b+jV5FSRSMYkjNG90ADHKnqyuEG1Y7+pvIX1hOBGIVJ0HA9Ij6xYTZXkGRu6V+WGYEPnDJ4Pi+EYwD400nxtrxwpGiEHWYzyjACHB2BKS9J3BDh4S/")) + dbFile.write(Base64.getDecoder().decode( + "A9mimmf7S7UBAAMAAhAAMcHy5r9xQ1C+WAUhavxa/wMEAAEAAAAEIAAWuDmigh57N8BZgj1w0GctkaqFTO7nPOroO5AnmUliFgUgAHCRnuiXHvLqSv2oJPMHI5QS9Ony+oIOww4kHcpz2wmEBgg" + + "AcBcAAAAAAAAHEAB2bInsMKh/zNzzBaegBa/kCCAAw3NSQcu+oGXwJvY9Ht6NSCC19uGFN+sEP9n9E3tNCvoJIADD/kYwUqnCSzrzYXu3tcJcvBKzTinwcSZQ769wYZ/oPwoEAAIAAAAABAAA0K0" + + "KdKTDfpMiRxKSe1xkvCDuBDdlDe/hiU3YnkhhCZk48sB9OV4uZI1LPJAgQ8HogjxMjAhnNThYdjlFQ9TTfsb3wUXH/7IK8n69a8IoWuRp9fnchQFnnArXcZeimeUREb/3jcwTXrIHGg+AN7MPQCD/b" + + "47us2h07oaAVvGFLCaLU111mJNZrdjNvO9BKODUbqfFFVhJhMAazjsdVpRfV3dbm0apdgluJQ8eUFzrF4YZUUkenG869o/yngD2cm1BxmG2QZe2fel/PSWRRFZ0i6zJm3h9CnYStnDL2N6lm9MPqOm3p" + + "KUk0uOG8p9P4U4UHMOnwIFw4RReO5wnqpMN4DFUCfM1qqx0fuqc/sy3DEgpRg3ENsHU7AbidobJzIqGOeK9ywU6Rp5peJrLjbIszedVWVPvxwt/xgAHcaqfntV86XcG8MlJElLSsK4fh9gspySNeyP43" + + "wnLjAdFGHq5OtAkhTTwXzHSZTxhazTjClssAHYAyTEQrlYRFl4+4apzdq5g3crGl1vR0Ekj22ytIsQXu9HYVTZ6pk6ESQAIg1qieDZQl4B1eEaRNnyvkfhgPIUDLuG4ULMGm/L1dur2nhHlooqgR8gHo5" + + "MNclghOKZdOhJNOvsd7/1XDsziizOqbocjJtBCEOWSgI1Ht1fbmG1nO9C6MrzkkxK+xQaXZDG2fmitUw2flN3OMky/RJgMD4LVdZLoaHj3uq86USXjDc0ql7lHeRRmOhIJ3X8DZObJmyhlylBGYwmYB/Pm" + + "Ouoe28iM8/wQ4Xr8bSYco5kxOK8Bii5TotDXqhVlTdajCo5NN33nqeSc/f+5BGL/CmdxannLel1bnEJrY2ESYXrjzUSM0pgnJlLNxZYynw/XMtSPbXI8/m2ciAP9iXv5efJMOv8O6dmzlbiYz7efSEpIDi" + + "wHG7HAIVnVzmy26jhyHXchaQpouPnwj/QhzTL1lRv0qA2K5+wNIexkKQa4G4iZhSHVzZTHY12rpnLDTxWv2GXHCvwdY5AD6jTJazimlTpbVOf+UvDIPiY2ksmhnaZU9Lc/ItkInhtZwW0e0XtdcgunaVr5Bv" + + "HTRRancrtRN23VAUlNzQ7Uror55JG3PxUThOX8XRmqJxMnIlgpNbv/tgqbks7zuCESLFjz0EM19QygatS+uHCWnsUp8sWl14bcrwGRoFsdPj/AFRGAv2xYwfMT8VBOZx1KpQ0vqxOx8t67pxpBUaH/Cqlh8J" + + "je7vxXT7wrXQK3bVjpk2uYncAkd7ruk0y7X/Jzvyu9cfoEDn6EMOS0b5aE6VoywNHhbo3dGyZq3K25jOGvgLzRSj9ETBNL8DTccaNzkcyXBsj/gqUZ1rYxVZHPaJWi2Cgy07b+jV5FSRSMYkjNG90ADHKnqyuEG1" + + "Y7+pvIX1hOBGIVJ0HA9Ij6xYTZXkGRu6V+WGYEPnDJ4Pi+EYwD400nxtrxwpGiEHWYzyjACHB2BKS9J3BDh4S/")) fun checkEntry(db: KeePassDatabase) { assertThat(db.rootGroup.getEntry("foo", "foo")!!.password!!.get().toString()).isEqualTo("bar")