diff --git a/.idea/libraries/bouncy_castle_provider.xml b/.idea/libraries/bouncy_castle_provider.xml new file mode 100644 index 000000000000..f9cc69c7e0b1 --- /dev/null +++ b/.idea/libraries/bouncy_castle_provider.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/build/groovy/org/jetbrains/intellij/build/CommunityLibraryLicenses.groovy b/build/groovy/org/jetbrains/intellij/build/CommunityLibraryLicenses.groovy index c432ad844bc4..bf7e6db493b3 100644 --- a/build/groovy/org/jetbrains/intellij/build/CommunityLibraryLicenses.groovy +++ b/build/groovy/org/jetbrains/intellij/build/CommunityLibraryLicenses.groovy @@ -78,6 +78,8 @@ class CommunityLibraryLicenses { licenseUrl: "http://www.apache.org/licenses/LICENSE-2.0.html"), new LibraryLicense(name: "bouncy-castle", version: "1.48", license: "MIT License", url: "http://bouncycastle.org", licenseUrl: "http://bouncycastle.org/licence.html"), + new LibraryLicense(name: "bouncy-castle-provider", version: "1.55", license: "MIT License", url: "http://bouncycastle.org", + licenseUrl: "http://bouncycastle.org/licence.html"), new LibraryLicense(name: "CGLib", libraryName: "CGLIB", version: "2.2.2", license: "Apache", url: "http://cglib.sourceforge.net/", licenseUrl: "http://www.apache.org/foundation/licence-FAQ.html"), new LibraryLicense(name: "classworlds", libraryName: "classworlds-1.1.jar", version: "1.1", license: "codehaus", diff --git a/lib/bcprov-jdk15on-155.jar b/lib/bcprov-jdk15on-155.jar new file mode 100644 index 000000000000..609925188434 Binary files /dev/null and b/lib/bcprov-jdk15on-155.jar differ diff --git a/lib/required_for_dist.txt b/lib/required_for_dist.txt index 35e36f0a0a99..3dea29a0fcb6 100644 --- a/lib/required_for_dist.txt +++ b/lib/required_for_dist.txt @@ -77,3 +77,4 @@ xml-apis.jar xmlrpc-2.0.jar xpp3-1.1.4-min.jar xstream-1.4.8.jar +bcprov-jdk15on-155.jar \ No newline at end of file diff --git a/lib/src/bcprov.zip b/lib/src/bcprov.zip new file mode 100644 index 000000000000..08f4d0be3ef5 Binary files /dev/null and b/lib/src/bcprov.zip differ diff --git a/platform/credential-store/credential-store.iml b/platform/credential-store/credential-store.iml index 77233e2da30d..de2131c9dd06 100644 --- a/platform/credential-store/credential-store.iml +++ b/platform/credential-store/credential-store.iml @@ -5,6 +5,7 @@ + @@ -15,5 +16,8 @@ + + + \ No newline at end of file diff --git a/platform/credential-store/resources/base.kdbx.xml b/platform/credential-store/resources/base.kdbx.xml new file mode 100644 index 000000000000..6b9c68d8fad4 --- /dev/null +++ b/platform/credential-store/resources/base.kdbx.xml @@ -0,0 +1,59 @@ + + + Keepass-Java-2 + + 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 + + + + + + + Root + + 48 + + ${creationDate} + ${creationDate} + ${creationDate} + ${creationDate} + False + 0 + ${creationDate} + + True + + True + True + AAAAAAAAAAAAAAAAAAAAAA== + + + + \ No newline at end of file diff --git a/platform/credential-store/src/FileCredentialStore.kt b/platform/credential-store/src/FileCredentialStore.kt index ff9c31323625..786830a46347 100644 --- a/platform/credential-store/src/FileCredentialStore.kt +++ b/platform/credential-store/src/FileCredentialStore.kt @@ -15,95 +15,72 @@ */ package com.intellij.credentialStore +import com.intellij.credentialStore.kdbx.KdbxPassword +import com.intellij.credentialStore.kdbx.KeePassDatabase +import com.intellij.credentialStore.kdbx.loadKdbx import com.intellij.ide.passwordSafe.PasswordStorage import com.intellij.ide.passwordSafe.impl.providers.masterKey.windows.WindowsCryptUtils import com.intellij.openapi.application.PathManager import com.intellij.openapi.util.SystemInfo -import com.intellij.openapi.util.io.BufferExposingByteArrayOutputStream import com.intellij.openapi.util.io.setOwnerPermissions -import com.intellij.util.* -import com.intellij.util.containers.ContainerUtil -import java.io.DataInputStream -import java.io.DataOutputStream +import com.intellij.util.EncryptionSupport +import com.intellij.util.delete +import com.intellij.util.readBytes +import com.intellij.util.writeSafe import java.nio.file.NoSuchFileException import java.nio.file.Path import java.nio.file.Paths import java.security.Key -import java.util.Base64 +import java.security.SecureRandom +import java.util.* import java.util.concurrent.atomic.AtomicBoolean import javax.crypto.spec.SecretKeySpec +private const val GROUP_NAME = "IntelliJ Platform" + internal class FileCredentialStore(keyToValue: Map? = null, baseDirectory: Path = Paths.get(PathManager.getConfigPath()), var memoryOnly: Boolean = false) : PasswordStorage, CredentialStore { - private val db = ContainerUtil.newConcurrentMap() + private val db: KeePassDatabase - private val dbFile = baseDirectory.resolve("cdb") + private val dbFile = baseDirectory.resolve("c.kdbx") private val masterKeyStorage = MasterKeyFileStorage(baseDirectory) - private var encryptionSupport: EncryptionSupport? = null - private val needToSave: AtomicBoolean init { if (keyToValue == null) { needToSave = AtomicBoolean(false) - run { - encryptionSupport = EncryptionSupport(SecretKeySpec(masterKeyStorage.get() ?: return@run, "AES")) - - val data: ByteArray - try { - data = encryptionSupport!!.decrypt(dbFile.readBytes()) - } - catch (e: NoSuchFileException) { - LOG.warn("key file exists, but db file not") - return@run - } - - val input = DataInputStream(data.inputStream()) - while (input.available() > 0) { - val serviceName = input.readUTF() - val accountName = input.readUTF() - db.put(CredentialAttributes(serviceName, accountName), Credentials(accountName, input.readUTF())) - } - } + db = masterKeyStorage.get()?.let { loadKdbx(dbFile, KdbxPassword(it)) } ?: KeePassDatabase() } else { needToSave = AtomicBoolean(!memoryOnly) - db.putAll(keyToValue) + + db = KeePassDatabase() + val group = db.rootGroup.getOrCreateGroup(GROUP_NAME) + for ((attributes, credentials) in keyToValue) { + val entry = db.newEntry(attributes.serviceName) + entry.userName = credentials.user + entry.password = credentials.password + group.addEntry(entry) + } } } @Synchronized fun save() { - if (memoryOnly || !needToSave.compareAndSet(true, false)) { + if (memoryOnly || !needToSave.compareAndSet(true, false) || !db.isDirty) { return } try { - var encryptionSupport = encryptionSupport - if (encryptionSupport == null) { - val masterKey = generateAesKey() + var masterKey = masterKeyStorage.get() + if (masterKey == null) { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + masterKey = Base64.getEncoder().withoutPadding().encode(bytes) masterKeyStorage.set(masterKey) - // set only if key stored successfully - encryptionSupport = EncryptionSupport(SecretKeySpec(masterKey, "AES")) - this.encryptionSupport = encryptionSupport } - if (db.isEmpty()) { - dbFile.delete() - masterKeyStorage.set(null) - return - } - - val byteOut = BufferExposingByteArrayOutputStream() - DataOutputStream(byteOut).use { out -> - for ((key, value) in db) { - out.writeUTF(key.serviceName) - out.writeUTF(key.accountName) - out.writeUTF(value.password) - } - } - - dbFile.writeSafe(encryptionSupport.encrypt(byteOut.internalBuffer, byteOut.size())) + dbFile.writeSafe { db.save(KdbxPassword(masterKey!!), it) } dbFile.setOwnerPermissions() } catch (e: Throwable) { @@ -120,12 +97,11 @@ internal class FileCredentialStore(keyToValue: Map.getPassword(requestor, accountName) if (password == null) { // try old key - as hash - val credentials = db.remove(toOldKey(requestor, accountName)) + val oldAttributes = toOldKey(requestor, accountName) + val credentials = db.rootGroup.getGroup(GROUP_NAME)?.removeEntry(oldAttributes.serviceName, oldAttributes.accountName!!) if (credentials != null) { set(CredentialAttributes(requestor, accountName), Credentials(accountName, credentials.password)) return credentials.password @@ -145,29 +122,33 @@ internal class FileCredentialStore(keyToValue: Map, accountName: String) = CredentialAttributes("IntelliJ Platform", toOldKey(MessageDigest.getInstance("SHA-256").digest("${requestor.name}/$accountName".toByteArray()))) -internal fun CredentialAttributes(requestor: Class<*>, accountName: String) = PasswordStorage.createAttributes(requestor, accountName) - fun joinData(user: String?, password: String?) = "${StringUtil.escapeChars(user.orEmpty(), '\\', '@')}@$password" fun splitData(data: String?): Credentials? { diff --git a/platform/credential-store/src/kdbx/Entry.kt b/platform/credential-store/src/kdbx/Entry.kt new file mode 100644 index 000000000000..39c34f5f1e9b --- /dev/null +++ b/platform/credential-store/src/kdbx/Entry.kt @@ -0,0 +1,181 @@ +/* + * 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 java.util.* + +/** + * Interface for a Database Entry. Database entries provide support + * at least for the Standard Properties of + * + * * Title + * * Username + * * Password + * * URL + * * Notes + * + + * + * Entries have a UUID and have an Icon, which are not properties. + + * + * Support for additional textual properties may be provided + * by a database implementation. + + * + * At some point support for binary properties may be added + * to this interface + + * + * Entries provide support for tracking when they are used. + * At some point support for accessing a history of modifications + * may be added to this interface + + * @author Jo + */ +interface Entry { + + /** + * Interface to implement when using the [.match] + * method + */ + interface Matcher { + fun matches(entry: Entry): Boolean + } + + /** + * Returns an XPath-like representation of this + * entry's ancestor groups and the title of this entry. + */ + val path: String + + /** + * Gets the value of a property. + + * + * All implementations of Entry are required to support reading and writing of + * [.STANDARD_PROPERTY_NAMES]. + * @param name the name of the property to get + * * + * @return a value or null if the property is not known, or if setting of arbitrary properties is not supported + */ + fun getProperty(name: String): String? + + /** + * Sets the value of a property. + + * + * Other than the [.STANDARD_PROPERTY_NAMES] support for this methd is optional. + + * @param name the name of the property to set + * * + * @param value the value to set it to + * * + * @throws UnsupportedOperationException if the name is not one of the standard properties and + * * non-standard properties are not supported. + */ + fun setProperty(name: String, value: String?) + + /** + * Returns a list of property names known to the entry. + + * + * All implementations of Entry are required to support reading and writing of + * [.STANDARD_PROPERTY_NAMES]. + * @return a list that is modifiable by the caller without affecting the Entry. + */ + val propertyNames: List + + val parent: KdbxGroup? + + /** + * Get the UUID of this entry. Databases (like KDB) that do not natively support + * UUIDs must provide a surrogate here. + + * @return a UUID + */ + val uuid: UUID + var userName: String? + var password: String? + + /** + * Gets the URL for this entry. + + * + * Implementations should Touch LastAccessedTime when this method is called. + + * @return a string representation of a URL + */ + /** + * Sets the url for this Entry. + + * + * Implementations should Touch LastModifiedTime when this method is called. + + * @param url the value to set + */ + var url: String? + + var title: String? + + var notes: String? + + var icon: Icon? + + val lastAccessTime: Date + + val creationTime: Date + + val expiryTime: Date + + val lastModificationTime: Date + + companion object { + + /** + * Standard properties are attributes of Entries that are accessible either by + * dedicated methods, such as getPassword, or by [.getProperty] + */ + + val STANDARD_PROPERTY_NAME_USER_NAME = "UserName" + val STANDARD_PROPERTY_NAME_PASSWORD = "Password" + val STANDARD_PROPERTY_NAME_URL = "URL" + val STANDARD_PROPERTY_NAME_TITLE = "Title" + val STANDARD_PROPERTY_NAME_NOTES = "Notes" + + val STANDARD_PROPERTY_NAMES = Collections.unmodifiableList(Arrays.asList( + STANDARD_PROPERTY_NAME_USER_NAME, + STANDARD_PROPERTY_NAME_PASSWORD, + STANDARD_PROPERTY_NAME_URL, + STANDARD_PROPERTY_NAME_TITLE, + STANDARD_PROPERTY_NAME_NOTES)) + } +} + +abstract class AbstractEntry : Entry { + override val path: String + get() { + val parent = this.parent + var result = "" + if (parent != null) { + result = parent.path + } + return result + title + } + + override fun toString() = this.path +} \ No newline at end of file diff --git a/platform/credential-store/src/kdbx/KdbxEntry.kt b/platform/credential-store/src/kdbx/KdbxEntry.kt new file mode 100644 index 000000000000..931ef1027e78 --- /dev/null +++ b/platform/credential-store/src/kdbx/KdbxEntry.kt @@ -0,0 +1,158 @@ +/* + * 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 org.linguafranca.pwdb.kdbx.dom.DomHelper +import org.w3c.dom.Element + +import java.text.ParseException +import java.util.* +import kotlin.reflect.KProperty + +class KdbxEntry(val element: Element, internal val database: KeePassDatabase, newElement: Boolean) : AbstractEntry() { + companion object { + internal var mandatoryEntryElements: Map = object : HashMap() { + init { + put(DomHelper.UUID_ELEMENT_NAME, DomHelper.UuidValueCreator()) + put(DomHelper.ICON_ELEMENT_NAME, DomHelper.ConstantValueCreator("2")) + put(DomHelper.TIMES_ELEMENT_NAME, DomHelper.ConstantValueCreator("")) + put(DomHelper.LAST_MODIFICATION_TIME_ELEMENT_NAME, DomHelper.DateValueCreator()) + put(DomHelper.CREATION_TIME_ELEMENT_NAME, DomHelper.DateValueCreator()) + put(DomHelper.LAST_ACCESS_TIME_ELEMENT_NAME, DomHelper.DateValueCreator()) + put(DomHelper.EXPIRY_TIME_ELEMENT_NAME, DomHelper.DateValueCreator()) + put(DomHelper.EXPIRES_ELEMENT_NAME, DomHelper.ConstantValueCreator("False")) + put(DomHelper.USAGE_COUNT_ELEMENT_NAME, DomHelper.ConstantValueCreator("0")) + put(DomHelper.LOCATION_CHANGED, DomHelper.DateValueCreator()) + } + } + } + + init { + if (newElement) { + DomHelper.ensureElements(element, mandatoryEntryElements) + ensureProperty("Notes") + ensureProperty("Title") + ensureProperty("URL") + ensureProperty("UserName") + ensureProperty("Password") + } + } + + override fun getProperty(name: String): String? { + val property = DomHelper.getElement(String.format(DomHelper.PROPERTY_ELEMENT_FORMAT, name), element, false) ?: return null + return DomHelper.getElementContent(DomHelper.VALUE_ELEMENT_NAME, property) + } + + override fun setProperty(name: String, value: String?) { + var property = DomHelper.getElement(String.format(DomHelper.PROPERTY_ELEMENT_FORMAT, name), element, false) + if (property == null) { + property = DomHelper.newElement("String", element) + DomHelper.setElementContent("Key", property, name) + } + DomHelper.setElementContent(DomHelper.VALUE_ELEMENT_NAME, property, value) + DomHelper.touchElement(DomHelper.LAST_MODIFICATION_TIME_ELEMENT_NAME, element) + database.isDirty = true + } + + override val propertyNames: List + get() { + val result = ArrayList() + val list = DomHelper.getElements("String", element) + for (listElement in list) { + DomHelper.getElementContent("Key", listElement)?.let { + result.add(it) + } + } + return result + } + + private fun ensureProperty(name: String) { + val property = DomHelper.getElement(String.format(DomHelper.PROPERTY_ELEMENT_FORMAT, name), element, false) + if (property == null) { + val container = DomHelper.newElement("String", element) + DomHelper.setElementContent("Key", container, name) + DomHelper.getElement("Value", container, true) + } + } + + override val parent: KdbxGroup? + get() = (element.parentNode as Element?)?.let { KdbxGroup(it, database, false) } + + override val uuid: UUID + get() = DomHelper.uuidFromBase64(DomHelper.getElementContent(DomHelper.UUID_ELEMENT_NAME, element)!!) + + override var userName: String? by PropertyDelegate("UserName") + + override var password: String? by PropertyDelegate("Password") + + override var url: String? by PropertyDelegate("URL") + + override var title: String? by PropertyDelegate("Title") + + override var notes: String? by PropertyDelegate("Notes") + + override var icon: Icon? + get() = DomIconWrapper(DomHelper.getElement(DomHelper.ICON_ELEMENT_NAME, element, false)!!) + set(value) { + DomHelper.getElement(DomHelper.ICON_ELEMENT_NAME, element, true)!!.textContent = value!!.index.toString() + DomHelper.touchElement(DomHelper.LAST_MODIFICATION_TIME_ELEMENT_NAME, element) + database.isDirty = true + } + + override val lastAccessTime: Date + get() = getTime(DomHelper.LAST_ACCESS_TIME_ELEMENT_NAME) + + private fun getTime(name: String): Date { + DomHelper.getElementContent(name, element)?.let { + try { + DomHelper.dateFormatter.parse(it) + } + catch (e: ParseException) { + return Date(0) + } + } + return Date(0) + } + + override val creationTime: Date + get() = getTime(DomHelper.CREATION_TIME_ELEMENT_NAME) + + override val expiryTime: Date + get() = getTime(DomHelper.EXPIRY_TIME_ELEMENT_NAME) + + override val lastModificationTime: Date + get() = getTime(DomHelper.LAST_MODIFICATION_TIME_ELEMENT_NAME) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + + val that = other as KdbxEntry? + return element == that!!.element && database == that.database + } + + override fun hashCode(): Int { + var result = element.hashCode() + result = 31 * result + database.hashCode() + return result + } +} + +private class PropertyDelegate(private val name: String) { + operator fun getValue(thisRef: KdbxEntry, property: KProperty<*>) = thisRef.getProperty(name) + + operator fun setValue(thisRef: KdbxEntry, property: KProperty<*>, value: String?) = thisRef.setProperty(name, value) +} \ No newline at end of file diff --git a/platform/credential-store/src/kdbx/KdbxGroup.kt b/platform/credential-store/src/kdbx/KdbxGroup.kt new file mode 100644 index 000000000000..02db4df17aa8 --- /dev/null +++ b/platform/credential-store/src/kdbx/KdbxGroup.kt @@ -0,0 +1,175 @@ +package com.intellij.credentialStore.kdbx + +import com.intellij.util.containers.Stack +import org.linguafranca.pwdb.kdbx.dom.DomHelper.* +import org.w3c.dom.Element +import java.util.* + +private val mandatoryGroupElements: Map = mapOf ( + UUID_ELEMENT_NAME to UuidValueCreator(), + NAME_ELEMENT_NAME to ConstantValueCreator(""), + NOTES_ELEMENT_NAME to ConstantValueCreator(""), + ICON_ELEMENT_NAME to ConstantValueCreator("2"), + TIMES_ELEMENT_NAME to ConstantValueCreator(""), + LAST_MODIFICATION_TIME_ELEMENT_NAME to DateValueCreator(), + CREATION_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() +) + +class KdbxGroup(private val element: Element, private val database: KeePassDatabase, isNewGroup: Boolean) { + init { + if (isNewGroup) { + ensureElements(element, mandatoryGroupElements) + } + } + + val isRootGroup: Boolean + get() = parent != null && (element.parentNode as Element?)?.tagName == "Root" + + var name: String + get() = getElementContent(NAME_ELEMENT_NAME, element)!! + set(value) { + setElementContent(NAME_ELEMENT_NAME, element, value) + database.isDirty = true + } + + val uuid: UUID + get() = uuidFromBase64(getElementContent(UUID_ELEMENT_NAME, element)!!) + + @Suppress("ConvertLambdaToReference") + var icon: Icon? + get() = getElement(ICON_ELEMENT_NAME, element, false)?.let { DomIconWrapper(it) } + set(value) { + setElementContent(ICON_ELEMENT_NAME, element, icon!!.index.toString()) + database.isDirty = true + } + + val parent: KdbxGroup? + get() { + val parent = element.parentNode as Element? ?: return null + // if the element is the root group there is no parent + if (element === element.ownerDocument.documentElement.getElementsByTagName(GROUP_ELEMENT_NAME).item(0)) { + return null + } + return KdbxGroup(parent, database, false) + } + + fun addGroup(group: KdbxGroup): KdbxGroup { + if (group.isRootGroup) { + throw IllegalStateException("Cannot set root group as child of another group") + } + + // skip if this is a new group with no parent + group.parent?.removeGroup(group) + element.appendChild(group.element) + touchElement("Times/LocationChanged", group.element) + database.isDirty = true + return group + } + + fun removeGroup(group: KdbxGroup): KdbxGroup { + element.removeChild(group.element) + database.isDirty = true + return group + } + + fun removeGroup(name: String) { + getGroup(name)?.let { removeGroup(it) } + } + + fun getGroup(name: String) = getGroup { it.name == name } + + fun getOrCreateGroup(name: String) = getGroup(name) ?: createGroup(name) + + fun createGroup(name: String) = addGroup(database.newGroup(name)) + + val entries: List + get() = getElements(ENTRY_ELEMENT_NAME, this.element).map { KdbxEntry(it, database, false) } + + private inline fun getGroup(matcher: (KdbxGroup) -> Boolean): KdbxGroup? { + for (groupElement in getElements(GROUP_ELEMENT_NAME, this.element)) { + val item = KdbxGroup(groupElement, database, false) + if (matcher(item)) { + return item + } + } + return null + } + + fun getEntry(matcher: (entry: Entry) -> Boolean): Entry? { + for (entryElement in getElements(ENTRY_ELEMENT_NAME, element)) { + val entry = KdbxEntry(entryElement, database, false) + if (matcher(entry)) { + return entry + } + } + + return null + } + + fun addEntry(entry: KdbxEntry): KdbxEntry { + if (entry.parent != null) { + entry.element.parentNode.removeChild(element) + } + element.appendChild(entry.element) + database.isDirty = true + return entry + } + + fun removeEntry(entry: Entry): Entry { + element.removeChild((entry as KdbxEntry).element) + database.isDirty = true + return entry + } + + fun getEntry(title: String, userName: String?) = getEntry { it.title == title && (it.userName == userName || userName == null) } + + fun getOrCreateEntry(title: String, userName: String?): Entry { + var entry = getEntry(title, userName) + if (entry == null) { + entry = database.newEntry() + entry.title = title + entry.userName = userName + addEntry(entry) + } + return entry + } + + fun removeEntry(title: String, userName: String?) = getEntry(title, userName)?.let { removeEntry(it) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + + val that = other as KdbxGroup? + return element == that!!.element && database == that.database + } + + 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() + } + + override fun toString() = this.path + + override fun hashCode(): Int { + var result = element.hashCode() + result = 31 * result + database.hashCode() + return result + } +} \ No newline at end of file diff --git a/platform/credential-store/src/kdbx/KeePassDatabase.kt b/platform/credential-store/src/kdbx/KeePassDatabase.kt new file mode 100644 index 000000000000..18df269749df --- /dev/null +++ b/platform/credential-store/src/kdbx/KeePassDatabase.kt @@ -0,0 +1,101 @@ +package com.intellij.credentialStore.kdbx + +import org.linguafranca.pwdb.kdbx.dom.DomHelper +import org.linguafranca.pwdb.kdbx.dom.DomSerializableDatabase +import org.w3c.dom.Element +import java.io.InputStream +import java.io.OutputStream +import javax.xml.xpath.XPathConstants + +class KeePassDatabase() { + internal val dbMeta: Element + get() { + val list = domDatabase.doc.documentElement.getElementsByTagName("Meta") + return if (list.length > 0) list.item(0) as Element else throw IllegalStateException("no meta") + } + + private val domDatabase = DomSerializableDatabase.createEmptyDatabase() + + @Volatile var isDirty: Boolean = false + internal set + + fun load(streamFormat: StreamFormat, credentials: KeePassCredentials, inputStream: InputStream) { + streamFormat.load(domDatabase, credentials, inputStream) + } + + var name: String? + get() = DomHelper.getElementContent("DatabaseName", dbMeta) + set(name) { + DomHelper.setElementContent("DatabaseName", dbMeta, name) + DomHelper.touchElement("DatabaseNameChanged", dbMeta) + isDirty = true + } + + fun save(credentials: KeePassCredentials, outputStream: OutputStream) { + KdbxStreamFormat().save(domDatabase, credentials, outputStream) + isDirty = false + } + + fun save(streamFormat: StreamFormat, credentials: KeePassCredentials, outputStream: OutputStream) { + streamFormat.save(domDatabase, credentials, outputStream) + isDirty = false + } + +// fun shouldProtect(name: String): Boolean { +// val protectionElement = DomHelper.getElement("MemoryProtection/Protect$name", dbMeta, false) ?: return false +// return protectionElement.textContent.toBoolean() +// } + + val rootGroup: KdbxGroup + get() = KdbxGroup(DomHelper.xpath.evaluate("/KeePassFile/Root/Group", domDatabase.doc, XPathConstants.NODE) as Element, this, false) + + fun newGroup(name: String): KdbxGroup { + val result = KdbxGroup(domDatabase.doc.createElement(DomHelper.GROUP_ELEMENT_NAME), this, true) + result.name = name + return result + } + + fun newEntry() = KdbxEntry(domDatabase.doc.createElement(DomHelper.ENTRY_ELEMENT_NAME), this, true) + + fun newEntry(title: String): KdbxEntry { + val result = newEntry() + result.title = title + return result + } + +// fun newIcon() = DomIconWrapper(domDatabase.doc.createElement(DomHelper.ICON_ELEMENT_NAME)) + +// fun newIcon(i: Int): Icon { +// val icon = newIcon() +// icon.index = i +// return icon +// } + + fun getDescription() = DomHelper.getElementContent("DatabaseDescription", dbMeta) + + fun setDescription(description: String) { + DomHelper.setElementContent("DatabaseDescription", dbMeta, description) + DomHelper.touchElement("DatabaseDescriptionChanged", dbMeta) + isDirty = true + } +} + +interface Icon { + var index: Int +} + +class DomIconWrapper(private val element: Element) : Icon { + override var index: Int + get() = Integer.parseInt(element.textContent) + set(index) { + element.textContent = index.toString() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || other !is Icon) return false + return this.index == other.index + } + + override fun hashCode() = index +} \ No newline at end of file diff --git a/platform/credential-store/src/kdbx/kdbx.kt b/platform/credential-store/src/kdbx/kdbx.kt new file mode 100644 index 000000000000..1013804176ef --- /dev/null +++ b/platform/credential-store/src/kdbx/kdbx.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2000-2016 JetBrains s.r.o. + * + * 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.intellij.util.inputStream +import java.io.InputStream +import java.nio.file.Path +import java.security.MessageDigest +import javax.xml.bind.DatatypeConverter +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.xpath.XPathConstants +import javax.xml.xpath.XPathFactory + +internal fun loadKdbx(file: Path, credentials: KeePassCredentials) = file.inputStream().use { + val db = KeePassDatabase() + db.load(KdbxStreamFormat(), credentials, it) + db +} + +class KdbxPassword(password: ByteArray) : KeePassCredentials { + override val key: ByteArray + + init { + val md = MessageDigest.getInstance("SHA-256") + key = md.digest(md.digest(password)) + } +} + +@Suppress("unused") +class KdbxKeyFile(password: ByteArray, inputStream: InputStream) : KeePassCredentials { + override val key: ByteArray + + init { + val md = MessageDigest.getInstance("SHA-256") + val pwKey = md.digest(password) + md.update(pwKey) + key = md.digest(loadKdbxKeyFile(inputStream) ?: throw IllegalStateException("Could not read key file")) + } +} + +fun loadKdbxKeyFile(inputStream: InputStream): ByteArray? { + val base64: String? + try { + val documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder() + val doc = documentBuilder.parse(inputStream) + base64 = XPathFactory.newInstance().newXPath().evaluate("//KeyFile/Key/Data/text()", doc, XPathConstants.STRING) as String? + if (base64 == null) { + return null + } + } + catch (e: Exception) { + return null + } + + return DatatypeConverter.parseBase64Binary(base64) +} + +interface KeePassCredentials { + val key: ByteArray +} \ No newline at end of file diff --git a/platform/credential-store/src/kdbx/streamFormat.kt b/platform/credential-store/src/kdbx/streamFormat.kt new file mode 100644 index 000000000000..c97cf574c472 --- /dev/null +++ b/platform/credential-store/src/kdbx/streamFormat.kt @@ -0,0 +1,46 @@ +package com.intellij.credentialStore.kdbx + +import org.linguafranca.pwdb.kdbx.KdbxHeader +import org.linguafranca.pwdb.kdbx.KdbxSerializer +import org.linguafranca.pwdb.kdbx.Salsa20Encryption +import org.linguafranca.pwdb.kdbx.SerializableDatabase +import java.io.InputStream +import java.io.OutputStream + +interface StreamFormat { + /** + * Class allows for serializing a database directly to or from a stream with no encryption etc + */ + class None : StreamFormat { + override fun load(db: SerializableDatabase, credentials: KeePassCredentials, inputStream: InputStream) { + db.load(inputStream) + } + + override fun save(db: SerializableDatabase, credentials: KeePassCredentials, outputStream: OutputStream) { + db.save(outputStream) + } + } + + fun load(db: SerializableDatabase, credentials: KeePassCredentials, inputStream: InputStream) + + fun save(db: SerializableDatabase, credentials: KeePassCredentials, outputStream: OutputStream) +} + +class KdbxStreamFormat : StreamFormat { + override fun load(db: SerializableDatabase, credentials: KeePassCredentials, inputStream: InputStream) { + val kdbxHeader = KdbxHeader() + KdbxSerializer.createUnencryptedInputStream(credentials, kdbxHeader, inputStream).use { + db.encryption = Salsa20Encryption(kdbxHeader.protectedStreamKey) + db.load(it) + } + } + + override fun save(db: SerializableDatabase, credentials: KeePassCredentials, outputStream: OutputStream) { + val kdbxHeader = KdbxHeader() + KdbxSerializer.createEncryptedOutputStream(credentials, kdbxHeader, outputStream).use { + db.headerHash = kdbxHeader.headerHash + db.encryption = Salsa20Encryption(kdbxHeader.protectedStreamKey) + db.save(it) + } + } +} \ No newline at end of file diff --git a/platform/credential-store/src/org/linguafranca/hashedblock/HashedBlockInputStream.java b/platform/credential-store/src/org/linguafranca/hashedblock/HashedBlockInputStream.java new file mode 100644 index 000000000000..95fc70d39191 --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/hashedblock/HashedBlockInputStream.java @@ -0,0 +1,206 @@ +/* + * 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 org.linguafranca.hashedblock; + +import org.jetbrains.annotations.NotNull; + +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +/** + * Takes an underlying input stream formatted as Hashed Blocks + * and provides the content of the blocks as an input stream + * + *

A Hashed block consists of: + * + *

    + *
  1. A 4 byte block sequence number, increments from 0 + *
  2. A 32 byte MD5 hash of the content + *
  3. A 4 byte length field + *
  4. Content + *
+ * + *

The stream of blocks is terminated with a 0 length 0 hash block. + * + *

Originally developed for KeePass. A KeePass hash block + * stream is little endian, i.e. the sequence + * number and length fields are low order byte first. + * + * @author Jo + */ +public class HashedBlockInputStream extends InputStream { + + private static MessageDigest md5; + static { + try { + md5 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + private static final int HASH_SIZE = 32; + private static final byte[] ZERO_HASH = new byte[HASH_SIZE]; + + private long expectedSequenceNumber = 0; + private boolean littleEndian = false; + private boolean done = false; + private InputStream inputStream; + private ByteArrayInputStream blockInputStream = new ByteArrayInputStream(new byte[0]); + + /** + * Create a Big Endian Hash Block Input Stream + * @param inputStream the input stream containing the hash blocks + */ + public HashedBlockInputStream(InputStream inputStream) { + this(inputStream, false); + } + + /** + * Create a Hash Block Input Stream with choice of endian encoding + * @param inputStream the input stream containing the hash blocks + * @param littleEndian true if the stream is little endian encoded + */ + public HashedBlockInputStream(InputStream inputStream, boolean littleEndian) { + this.inputStream = inputStream; + this.littleEndian = littleEndian; + } + + @Override + public int read(@NotNull byte[] b, int offset, int length) throws IOException { + return get(b, offset, length); + } + + @Override + public int read() throws IOException { + byte[] buffer = new byte[1]; + if (get(buffer, 0, 1) != 1) { + throw new IOException("Could not read int"); + } + return buffer[0] & 0xFF; + } + + @Override + public void close() throws IOException { + inputStream.close(); + } + + /** + * Gets bytes from the internal buffer and replenishes the buffer as necessary + * @param b a byte array to fill + * @param offset the offset to strat from + * @param length the number of bytes to return + * @return the number of bytes actually returned, , -1 if end of file + * @throws IOException + */ + protected int get(byte[] b, int offset, int length) throws IOException { + if (done) { + return -1; + } + int totalBytesRead = 0; + int bytesRead; + while ((bytesRead = blockInputStream.read(b, offset, length)) < length && !done) { + if (bytesRead == -1) { + load(); + } else { + offset += bytesRead; + length -= bytesRead; + totalBytesRead += bytesRead; + } + } + return bytesRead > 0 ? totalBytesRead + bytesRead : totalBytesRead; + } + + /** + * Reload the internal buffer from the underlying input stream + * @throws IOException + */ + protected void load() throws IOException { + // read the sequence number of the block + long sequenceNumber = readUInt(); + if (sequenceNumber != expectedSequenceNumber) { + throw new IllegalStateException("Expected sequence number " + + expectedSequenceNumber + " got " + sequenceNumber); + } + expectedSequenceNumber++; + + // get the block hash + byte[] hash = new byte[HASH_SIZE]; + readFully(hash); + + // get the length + long readLength = readUInt(); + if (readLength < 0) { + throw new IllegalStateException("Got negative length for block"); + } + // length 0 means end of file + if (readLength == 0) { + if (!Arrays.equals(hash, ZERO_HASH)) { + throw new IllegalStateException("Block hash was not zero on final block"); + } + done = true; + return; + } + + // get the new buffer + byte[] readBuffer = new byte[(int) readLength]; + readFully(readBuffer); + + // check the hash + md5.update(readBuffer); + if (!Arrays.equals(md5.digest(), hash)) { + throw new IllegalStateException("MD5 check failed while reading HashBlock"); + } + blockInputStream = new ByteArrayInputStream(readBuffer); + } + + /** + * Read an unsigned 4 byte int decoding from the endian format + * @return a long holding the value read + * @throws IOException + */ + private long readUInt() throws IOException { + byte[] buf = new byte[4]; + readFully(buf); + if (littleEndian) { + return buf[3] << 24 | (buf[2] & 0xFF) << 16 | (buf[1] & 0xFF) << 8 | (buf[0] & 0xFF); + } + return buf[0] << 24 | (buf[1] & 0xFF) << 16 | (buf[2] & 0xFF) << 8 | (buf[3] & 0xFF); + } + + /** + * Fill the buffer passed + * @param buffer the buffer to fill + * @throws IOException if the buffer could not be filled + */ + private void readFully(byte[] buffer) throws IOException { + int bytesToRead = buffer.length; + int bytesSoFar = 0; + while (bytesSoFar < buffer.length) { + int bytesRead = inputStream.read(buffer, bytesSoFar, bytesToRead); + if (bytesRead <= 0) { + throw new EOFException(); + } + bytesSoFar += bytesRead; + bytesToRead -= bytesRead; + } + } +} diff --git a/platform/credential-store/src/org/linguafranca/hashedblock/HashedBlockOutputStream.java b/platform/credential-store/src/org/linguafranca/hashedblock/HashedBlockOutputStream.java new file mode 100644 index 000000000000..f6cff537267e --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/hashedblock/HashedBlockOutputStream.java @@ -0,0 +1,183 @@ +/* + * 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 org.linguafranca.hashedblock; + +import org.jetbrains.annotations.NotNull; + +import java.io.ByteArrayOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Takes a stream of data and formats as Hashed Blocks to the underlying output stream. + * + *

A Hashed block consists of: + * + *

    + *
  1. A 4 byte block sequence number, increments from 0 + *
  2. A 32 byte MD5 hash of the content + *
  3. A 4 byte length field + *
  4. Content + *
+ * + *

The stream of blocks is terminated with a 0 length 0 hash block. + * + *

Originally developed for KeePass. A KeePass hash block + * stream is little endian, i.e. the sequence + * number and length fields are low order byte first. + * + * @author Jo + */ +public class HashedBlockOutputStream extends OutputStream { + + private static MessageDigest md5; + static { + try { + md5 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + private static final int BLOCK_SIZE = 8 * 1024; + private static final int HASH_SIZE = 32; + private static final byte[] ZERO_HASH = new byte[HASH_SIZE]; + + private int nextSequenceNumber = 0; + private boolean littleEndian = false; + private OutputStream outputStream; + private ByteArrayOutputStream blockOutputStream = new ByteArrayOutputStream(); + private boolean isClosed = false; + + /** + * Create a BigEndian Hash Block Output Stream + * @param outputStream the output stream to receive the hash blocks + */ + public HashedBlockOutputStream(OutputStream outputStream) { + this(outputStream, false); + } + + /** + * Create a Hash Block Output Stream with choice of endian encoding + * @param outputStream the output stream to receive the hash blocks + * @param littleEndian true to encode in a little endian way + */ + public HashedBlockOutputStream(OutputStream outputStream, boolean littleEndian) { + this.outputStream = outputStream; + this.littleEndian = littleEndian; + } + + @Override + public void write(int i) throws IOException { + byte[] buf = new byte[1]; + buf[0] = (byte) i; + put(buf, 0, 1); + } + + @Override + public void write(@NotNull byte[] b, int offset, int count) throws IOException { + put(b, offset, count); + } + + + @Override + public void flush() throws IOException { + save(); + } + + @Override + public void close() throws IOException { + if (isClosed) { + throw new EOFException(); + } + flush(); + writeInt(nextSequenceNumber); + outputStream.write(ZERO_HASH); + writeInt(0); + isClosed = true; + outputStream.flush(); + outputStream.close(); + } + + /** + * Writes to the internal buffer, and writes to the underlying output stream + * as necessary as {@link #BLOCK_SIZE} blocks + * @param b the byte array to write + * @param offset offset in the byte array + * @param length number of bytes to write + * @throws IOException + */ + protected void put(byte[] b, int offset, int length) throws IOException { + if (isClosed) { + throw new EOFException(); + } + while (length > 0) { + int bytesToWrite = Math.min(BLOCK_SIZE - blockOutputStream.size(), length); + blockOutputStream.write(b, offset, bytesToWrite); + if (blockOutputStream.size() >= BLOCK_SIZE) { + save(); + } + offset += bytesToWrite; + length -= bytesToWrite; + } + } + + /** + * Saqve the internal buffer to the underlying stream as a hash block + * @throws IOException + */ + protected void save() throws IOException { + // if there's nothing to save don't do anything + if (blockOutputStream.size() == 0) { + return; + } + // write and increment the block sequence no + writeInt(nextSequenceNumber++); + + // calculate the hash of the buffer + byte[] buffer = blockOutputStream.toByteArray(); + md5.update(buffer); + outputStream.write(md5.digest()); + + // write the buffer's length + writeInt(buffer.length); + + // write the buffer + outputStream.write(buffer); + + // push the contents to disk etc. + outputStream.flush(); + + // reset the internal output buffer for reuse + blockOutputStream.reset(); + } + + /** + * Write a 4 byte int value to the underlying stream in appropriate endian format + * @param value the value to write + * @throws IOException + */ + protected void writeInt(int value) throws IOException { + int output = value; + if (littleEndian) { + output = Integer.reverseBytes(value); + } + outputStream.write(new byte[]{(byte) (output >> 24), (byte) (output >> 16), (byte) (output >> 8), (byte) output}); + } +} diff --git a/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxHeader.java b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxHeader.java new file mode 100644 index 000000000000..a321ece4f91d --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxHeader.java @@ -0,0 +1,206 @@ +/* + * 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 org.linguafranca.pwdb.kdbx; + +import org.linguafranca.security.Encryption; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.security.SecureRandom; +import java.util.UUID; + +/** + * This class represents the header portion of a KeePass KDBX file or stream. The header is received in + * plain text and describes the encryption and compression of the remainder of the file. + * + *

It is a factory for encryption and decryption streams and contains a hash of its own serialization. + * + *

While KDBX streams are Little-Endian, data is passed to and from this class in standard Java byte order. + * + * @author jo + */ +public class KdbxHeader { + + /** + * The ordinal 0 represents uncompressed and 1 GZip compressed + */ + public enum CompressionFlags { + NONE, GZIP + } + + /** + * The ordinals represent various types of encryption that may + * be applied to fields within the unencrypted data + * + * @see StreamFormat + * @see KdbxStreamFormat + */ + public enum ProtectedStreamAlgorithm { + NONE, ARC_FOUR, SALSA_20 + } + + /** + * This UUID denotes that AES Cipher is in use. No other values are known. + */ + public static final UUID AES_CIPHER = UUID.fromString("31C1F2E6-BF71-4350-BE58-05216AFC5AFF"); + + /* the cipher in use */ + private UUID cipherUuid; + /* whether the data is compressed */ + private CompressionFlags compressionFlags; + private byte [] masterSeed; + private byte[] transformSeed; + private long transformRounds; + private byte[] encryptionIv; + private byte[] protectedStreamKey; + private ProtectedStreamAlgorithm protectedStreamAlgorithm; + /* these bytes appear in cipher text immediately following the header */ + private byte[] streamStartBytes; + /* not transmitted as part of the header, used in the XML payload, so calculated + * on transmission or receipt */ + private byte[] headerHash; + + /** + * Construct a default KDBX header + */ + public KdbxHeader() { + SecureRandom random = new SecureRandom(); + cipherUuid = AES_CIPHER; + compressionFlags = CompressionFlags.GZIP; + masterSeed = random.generateSeed(32); + transformSeed = random.generateSeed(32); + transformRounds = 6000; + encryptionIv = random.generateSeed(16); + protectedStreamKey = random.generateSeed(32); + streamStartBytes = new byte[32]; + protectedStreamAlgorithm = ProtectedStreamAlgorithm.SALSA_20; + } + + /** + * Create a decrypted input stream using supplied digest and this header + * apply decryption to the passed encrypted input stream + * + * @param digest the key digest + * @param inputStream the encrypted input stream + * @return a decrypted stream + * @throws IOException + */ + public InputStream createDecryptedStream(byte[] digest, InputStream inputStream) throws IOException { + byte[] finalKeyDigest = Encryption.getFinalKeyDigest(digest, getMasterSeed(), getTransformSeed(), getTransformRounds()); + return Encryption.getDecryptedInputStream(inputStream, finalKeyDigest, getEncryptionIv()); + } + + /** + * Create an unencrypted outputstream using the supplied digest and this header + * and use the supplied output stream to write encrypted data. + * @param digest the key digest + * @param outputStream the output stream which is the destination for encrypted data + * @return an output stream to write unencrypted data to + * @throws IOException + */ + public OutputStream createEncryptedStream(byte[] digest, OutputStream outputStream) throws IOException { + byte[] finalKeyDigest = Encryption.getFinalKeyDigest(digest, getMasterSeed(), getTransformSeed(), getTransformRounds()); + return Encryption.getEncryptedOutputStream(outputStream, finalKeyDigest, getEncryptionIv()); + } + + public UUID getCipherUuid() { + return cipherUuid; + } + + public CompressionFlags getCompressionFlags() { + return compressionFlags; + } + + public byte[] getMasterSeed() { + return masterSeed; + } + + public byte[] getTransformSeed() { + return transformSeed; + } + + public long getTransformRounds() { + return transformRounds; + } + + public byte[] getEncryptionIv() { + return encryptionIv; + } + + public byte[] getProtectedStreamKey() { + return protectedStreamKey; + } + + public byte[] getStreamStartBytes() { + return streamStartBytes; + } + + public ProtectedStreamAlgorithm getProtectedStreamAlgorithm() { + return protectedStreamAlgorithm; + } + + public byte[] getHeaderHash() { + return headerHash; + } + + public void setCipherUuid(byte[] uuid) { + ByteBuffer b = ByteBuffer.wrap(uuid); + UUID incoming = new UUID(b.getLong(), b.getLong(8)); + if (!incoming.equals(AES_CIPHER)) { + throw new IllegalStateException("Unknown Cipher UUID " + incoming.toString()); + } + this.cipherUuid = incoming; + } + + public void setCompressionFlags(int flags) { + this.compressionFlags = CompressionFlags.values()[flags]; + } + + public void setMasterSeed(byte[] masterSeed) { + this.masterSeed = masterSeed; + } + + public void setTransformSeed(byte[] transformSeed) { + this.transformSeed = transformSeed; + } + + public void setTransformRounds(long transformRounds) { + this.transformRounds = transformRounds; + } + + public void setEncryptionIv(byte[] encryptionIv) { + this.encryptionIv = encryptionIv; + } + + public void setProtectedStreamKey(byte[] protectedStreamKey) { + this.protectedStreamKey = protectedStreamKey; + } + + public void setStreamStartBytes(byte[] streamStartBytes) { + this.streamStartBytes = streamStartBytes; + } + + public void setInnerRandomStreamId(int innerRandomStreamId) { + this.protectedStreamAlgorithm = ProtectedStreamAlgorithm.values()[innerRandomStreamId]; + } + + public void setHeaderHash(byte[] headerHash) { + this.headerHash = headerHash; + } +} diff --git a/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxSerializer.java b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxSerializer.java new file mode 100644 index 000000000000..2e3a12c762df --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxSerializer.java @@ -0,0 +1,341 @@ +/* + * 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 org.linguafranca.pwdb.kdbx; + +import com.google.common.io.LittleEndianDataInputStream; +import com.google.common.io.LittleEndianDataOutputStream; +import com.intellij.credentialStore.kdbx.KeePassCredentials; +import org.linguafranca.hashedblock.HashedBlockInputStream; +import org.linguafranca.hashedblock.HashedBlockOutputStream; +import org.linguafranca.security.Encryption; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.security.DigestInputStream; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +/** + * This class provides static methods for the encryption and decryption of Keepass KDBX files. + *

+ * A KDBX file is little-endian and consists of the following: + *

    + *
  1. An unencrypted portion
  2. + *
      + *
    1. 8 bytes Magic number
    2. + *
    3. 4 bytes version
    4. + *
    5. A header containing details of the encryption of the remainder of the file
    6. + *

      The header fields are encoded using a TLV style. The Type is an enumeratrion 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.

      + *
    + *
  3. An encrypted portion
  4. + *
      + *
    1. A sequence of bytes contained in the header. If they don't match, decryption has not worked.
    2. + *
    3. A payload serialized in Hashed Block format.
    4. + *

      The content of this payload is expected to be a Keepass Database in XML format.

      + *
    + *
+ *

+ * The methods in this class provide support for serializing and deserializing plain text payload content + * to and from the above format. + *

+ * @author jo + */ +public class KdbxSerializer { + + // make entirely static + private KdbxSerializer() {} + + /** + * Provides the payload of a KDBX file as an unencrypted {@link 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 + * @throws IOException + */ + public static InputStream createUnencryptedInputStream(KeePassCredentials credentials, KdbxHeader kdbxHeader, InputStream inputStream) throws IOException { + + readKdbxHeader(kdbxHeader, inputStream); + + InputStream decryptedInputStream = kdbxHeader.createDecryptedStream(credentials.getKey(), inputStream); + + checkStartBytes(kdbxHeader, decryptedInputStream); + + HashedBlockInputStream blockInputStream = new HashedBlockInputStream(decryptedInputStream, true); + + if (kdbxHeader.getCompressionFlags().equals(KdbxHeader.CompressionFlags.NONE)) { + return blockInputStream; + } + return new GZIPInputStream(blockInputStream); + } + + /** + * Provides an {@link 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 + * @throws IOException + */ + public static OutputStream createEncryptedOutputStream(KeePassCredentials credentials, KdbxHeader kdbxHeader, OutputStream outputStream) throws IOException { + + writeKdbxHeader(kdbxHeader, outputStream); + + OutputStream encryptedOutputStream = kdbxHeader.createEncryptedStream(credentials.getKey(), outputStream); + + writeStartBytes(kdbxHeader, encryptedOutputStream); + + HashedBlockOutputStream blockOutputStream = new HashedBlockOutputStream(encryptedOutputStream, true); + + if(kdbxHeader.getCompressionFlags().equals(KdbxHeader.CompressionFlags.NONE)) { + return blockOutputStream; + } + return new GZIPOutputStream(blockOutputStream); + } + + + private static void checkStartBytes(KdbxHeader kdbxHeader, InputStream decryptedInputStream) throws IOException { + LittleEndianDataInputStream ledis = new LittleEndianDataInputStream(decryptedInputStream); + + byte [] startBytes = new byte[32]; + ledis.readFully(startBytes); + if (!Arrays.equals(startBytes, kdbxHeader.getStreamStartBytes())) { + throw new IllegalStateException("Inconsistent stream bytes"); + } + } + + private static void writeStartBytes(KdbxHeader kdbxHeader, OutputStream encryptedOutputStream) throws IOException { + LittleEndianDataOutputStream ledos = new LittleEndianDataOutputStream(encryptedOutputStream); + ledos.write(kdbxHeader.getStreamStartBytes()); + } + + private static final int SIG1 = 0x9AA2D903; + private static final int SIG2 = 0xB54BFB67; + private static final int FILE_VERSION_CRITICAL_MASK = 0xFFFF0000; + private static final int FILE_VERSION_32 = 0x00030001; + + private static class HeaderType { + static final byte END = 0; + static final byte COMMENT = 1; + static final byte CIPHER_ID = 2; + static final byte COMPRESSION_FLAGS = 3; + static final byte MASTER_SEED = 4; + static final byte TRANSFORM_SEED = 5; + static final byte TRANSFORM_ROUNDS = 6; + static final byte ENCRYPTION_IV = 7; + static final byte PROTECTED_STREAM_KEY = 8; + static final byte STREAM_START_BYTES = 9; + static final byte INNER_RANDOM_STREAM_ID = 10; + } + + /** + * Read two lots of 4 bytes and verify that they satisfy the signature of a + * kdbx file; + * @param ledis an input stream + * @return true if it looks like this is a kdbx file + * @throws IOException + */ + private static boolean verifyMagicNumber(LittleEndianDataInputStream ledis) throws IOException { + int sig1 = ledis.readInt(); + int sig2 = ledis.readInt(); + return sig1 == SIG1 && sig2 == SIG2; + } + + /** + * Read 4 bytes and make sure they conform to expectations of file version + * @param ledis an input stream + * @return true if it looks like we understand this file version + * @throws IOException + */ + private static boolean verifyFileVersion(LittleEndianDataInputStream ledis) throws IOException { + return ((ledis.readInt() & FILE_VERSION_CRITICAL_MASK) <= (FILE_VERSION_32 & 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 + * @throws IOException + */ + public static KdbxHeader readKdbxHeader(KdbxHeader kdbxHeader, InputStream inputStream) throws IOException { + + MessageDigest digest = Encryption.getMessageDigestInstance(); + // we do not close this stream, otherwise we lose our place in the underlying stream + DigestInputStream digestInputStream = new DigestInputStream(inputStream, digest); + // we do not close this stream, otherwise we lose our place in the underlying stream + LittleEndianDataInputStream ledis = new LittleEndianDataInputStream(digestInputStream); + + if (!verifyMagicNumber(ledis)) { + throw new IllegalStateException("Magic number did not match"); + } + + if (!verifyFileVersion(ledis)) { + throw new IllegalStateException("File version did not match"); + } + + byte headerType; + while ((headerType = ledis.readByte()) != HeaderType.END) { + switch (headerType) { + + case HeaderType.COMMENT: + getByteArray(ledis); + break; + + case HeaderType.CIPHER_ID: + kdbxHeader.setCipherUuid(getByteArray(ledis)); + break; + + case HeaderType.COMPRESSION_FLAGS: + kdbxHeader.setCompressionFlags(getInt(ledis)); + break; + + case HeaderType.MASTER_SEED: + kdbxHeader.setMasterSeed(getByteArray(ledis)); + break; + + case HeaderType.TRANSFORM_SEED: + kdbxHeader.setTransformSeed(getByteArray(ledis)); + break; + + case HeaderType.TRANSFORM_ROUNDS: + kdbxHeader.setTransformRounds(getLong(ledis)); + break; + + case HeaderType.ENCRYPTION_IV: + kdbxHeader.setEncryptionIv(getByteArray(ledis)); + break; + + case HeaderType.PROTECTED_STREAM_KEY: + kdbxHeader.setProtectedStreamKey(getByteArray(ledis)); + break; + + case HeaderType.STREAM_START_BYTES: + kdbxHeader.setStreamStartBytes(getByteArray(ledis)); + break; + + case HeaderType.INNER_RANDOM_STREAM_ID: + kdbxHeader.setInnerRandomStreamId(getInt(ledis)); + break; + + default: throw new IllegalStateException("Unknown File Header"); + } + } + + // consume length etc. following END flag + getByteArray(ledis); + + kdbxHeader.setHeaderHash(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 + * @throws IOException + */ + public static void writeKdbxHeader(KdbxHeader kdbxHeader, OutputStream outputStream) throws IOException { + MessageDigest messageDigest = Encryption.getMessageDigestInstance(); + DigestOutputStream digestOutputStream = new DigestOutputStream(outputStream, messageDigest); + LittleEndianDataOutputStream ledos = new LittleEndianDataOutputStream(digestOutputStream); + + // write the magic number + ledos.writeInt(SIG1); + ledos.writeInt(SIG2); + // write a file version + ledos.writeInt(FILE_VERSION_32); + + ledos.writeByte(HeaderType.CIPHER_ID); + ledos.writeShort(16); + byte[] b = new byte[16]; + ByteBuffer bb = ByteBuffer.wrap(b); + bb.putLong(kdbxHeader.getCipherUuid().getMostSignificantBits()); + bb.putLong(8, kdbxHeader.getCipherUuid().getLeastSignificantBits()); + ledos.write(b); + + ledos.writeByte(HeaderType.COMPRESSION_FLAGS); + ledos.writeShort(4); + ledos.writeInt(kdbxHeader.getCompressionFlags().ordinal()); + + ledos.writeByte(HeaderType.MASTER_SEED); + ledos.writeShort(kdbxHeader.getMasterSeed().length); + ledos.write(kdbxHeader.getMasterSeed()); + + ledos.writeByte(HeaderType.TRANSFORM_SEED); + ledos.writeShort(kdbxHeader.getTransformSeed().length); + ledos.write(kdbxHeader.getTransformSeed()); + + ledos.writeByte(HeaderType.TRANSFORM_ROUNDS); + ledos.writeShort(8); + ledos.writeLong(kdbxHeader.getTransformRounds()); + + ledos.writeByte(HeaderType.ENCRYPTION_IV); + ledos.writeShort(kdbxHeader.getEncryptionIv().length); + ledos.write(kdbxHeader.getEncryptionIv()); + + ledos.writeByte(HeaderType.PROTECTED_STREAM_KEY); + ledos.writeShort(kdbxHeader.getProtectedStreamKey().length); + ledos.write(kdbxHeader.getProtectedStreamKey()); + + ledos.writeByte(HeaderType.STREAM_START_BYTES); + ledos.writeShort(kdbxHeader.getStreamStartBytes().length); + ledos.write(kdbxHeader.getStreamStartBytes()); + + ledos.writeByte(HeaderType.INNER_RANDOM_STREAM_ID); + ledos.writeShort(4); + ledos.writeInt(kdbxHeader.getProtectedStreamAlgorithm().ordinal()); + + ledos.writeByte(HeaderType.END); + ledos.writeShort(0); + + MessageDigest digest = digestOutputStream.getMessageDigest(); + kdbxHeader.setHeaderHash(digest.digest()); + } + + + private static int getInt(LittleEndianDataInputStream ledis) throws IOException { + short fieldLength = ledis.readShort(); + if (fieldLength != 4) { + throw new IllegalStateException("Int required but length was " + fieldLength); + } + return ledis.readInt(); + } + + private static long getLong(LittleEndianDataInputStream ledis) throws IOException { + short fieldLength = ledis.readShort(); + if (fieldLength != 8) { + throw new IllegalStateException("Long required but length was " + fieldLength); + } + return ledis.readLong(); + } + + private static byte [] getByteArray(LittleEndianDataInputStream ledis) throws IOException { + short fieldLength = ledis.readShort(); + byte [] value = new byte[fieldLength]; + ledis.readFully(value); + return value; + } +} diff --git a/platform/credential-store/src/org/linguafranca/pwdb/kdbx/Salsa20Encryption.java b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/Salsa20Encryption.java new file mode 100644 index 000000000000..0be497d559e4 --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/Salsa20Encryption.java @@ -0,0 +1,91 @@ +/* + * 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 org.linguafranca.pwdb.kdbx; + +import org.bouncycastle.crypto.engines.Salsa20Engine; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.crypto.params.ParametersWithIV; +import org.linguafranca.security.Encryption; + +import javax.xml.bind.DatatypeConverter; +import java.security.MessageDigest; + +/** + * A helper class for Salsa20 encryption. + * + *

Salsa20 doesn't quite fit the memory model + * supposed by SerializableDatabase.Encryption - all encrypted + * items have to be en/decrypted in order of encryption, + * i.e. in document order and at the same time. + * + *

The encrypt and decrypt methods + * actually do the same thing. They are here + * only to fulfill the interface contract. + * + * @author jo + */ +public class Salsa20Encryption implements SerializableDatabase.Encryption { + + private final Salsa20Engine salsa20; + private final byte[] key; + + private static final byte[] SALSA20_IV = DatatypeConverter.parseHexBinary("E830094B97205D2A"); + + /** + * Creates a Salsa20 engine + * + * @param key the key to use + * @return an initialized Salsa20 engine + */ + public static Salsa20Engine createSalsa20(byte[] key) { + MessageDigest md = Encryption.getMessageDigestInstance(); + KeyParameter keyParameter = new KeyParameter(md.digest(key)); + ParametersWithIV ivParameter = new ParametersWithIV(keyParameter, SALSA20_IV); + Salsa20Engine engine = new Salsa20Engine(); + engine.init(true, ivParameter); + return engine; + } + + /** + * Constructor creates engine used for both encryption and decryption + * + * @param key the key to use + */ + public Salsa20Encryption(byte[] key) { + this.key = key; + salsa20 = createSalsa20(key); + } + + @Override + public byte[] getKey() { + return key; + } + + @Override + public byte[] decrypt(byte[] encryptedText) { + byte[] output = new byte[encryptedText.length]; + salsa20.processBytes(encryptedText, 0, encryptedText.length, output, 0); + return output; + } + + @Override + public byte[] encrypt(byte[] decryptedText) { + byte[] output = new byte[decryptedText.length]; + salsa20.processBytes(decryptedText, 0, decryptedText.length, output, 0); + return output; + } +} diff --git a/platform/credential-store/src/org/linguafranca/pwdb/kdbx/SerializableDatabase.java b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/SerializableDatabase.java new file mode 100644 index 000000000000..46ee255c82b9 --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/SerializableDatabase.java @@ -0,0 +1,57 @@ +/* + * 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 org.linguafranca.pwdb.kdbx; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * This interface allows for serialization and deserialization of KDBX databases. + * + *

Databases instantiate themselves from a stream and serialize to a stream, + * and need to be able to encrypt and decrypt data (e.g. Protected fields in KDBX format). + * + *

KDBX databases contain a header hash (i.e. a hash of the contents of + * some portion of the {@link StreamFormat} they have been loaded from or saved to. + * Which means that databases must support the setting of this value after the header + * has been written on save, and reading the value after load to allow for integrity checking. + * + * @author jo + */ +public interface SerializableDatabase { + + interface Encryption { + byte[] getKey(); + + byte[] decrypt(byte[] encryptedText); + + byte[] encrypt(byte[] decryptedText); + } + + SerializableDatabase load(InputStream inputStream) throws IOException; + + void save(OutputStream outputStream) throws IOException; + + Encryption getEncryption(); + + void setEncryption(Encryption encryption); + + byte[] getHeaderHash(); + + void setHeaderHash(byte[] hash); +} diff --git a/platform/credential-store/src/org/linguafranca/pwdb/kdbx/dom/DomHelper.java b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/dom/DomHelper.java new file mode 100644 index 000000000000..7d283fd03ba1 --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/dom/DomHelper.java @@ -0,0 +1,223 @@ +/* + * 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 org.linguafranca.pwdb.kdbx.dom; + +import com.intellij.util.text.SyncDateFormat; +import org.apache.commons.codec.binary.Hex; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; +import java.nio.ByteBuffer; +import java.text.SimpleDateFormat; +import java.util.*; + +//import javax.xml.bind.DatatypeConverter; + +/** + * The class contains static helper methods for access to the underlying XML DOM + * + * @author jo + */ +public class DomHelper { + + public static final XPath xpath = XPathFactory.newInstance().newXPath(); + + public static SyncDateFormat dateFormatter = new SyncDateFormat(new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")); + + public static final String GROUP_ELEMENT_NAME = "Group"; + public static final String ENTRY_ELEMENT_NAME = "Entry"; + public static final String ICON_ELEMENT_NAME = "IconID"; + public static final String UUID_ELEMENT_NAME = "UUID"; + public static final String NAME_ELEMENT_NAME = "Name"; + public static final String NOTES_ELEMENT_NAME = "Notes"; + public static final String TIMES_ELEMENT_NAME = "Times"; + public static final String IS_EXPANDED = "IsExpanded"; + + static final String HISTORY_ELEMENT_NAME = "History"; + + public static final String LAST_MODIFICATION_TIME_ELEMENT_NAME = "Times/LastModificationTime"; + public static final String CREATION_TIME_ELEMENT_NAME = "Times/CreationTime"; + public static final String LAST_ACCESS_TIME_ELEMENT_NAME = "Times/LastAccessTime"; + public static final String EXPIRY_TIME_ELEMENT_NAME = "Times/ExpiryTime"; + public static final String EXPIRES_ELEMENT_NAME = "Times/Expires"; + public static final String USAGE_COUNT_ELEMENT_NAME = "Times/UsageCount"; + public static final String LOCATION_CHANGED = "Times/LocationChanged"; + + public static final String PROPERTY_ELEMENT_FORMAT = "String[Key/text()='%s']"; + public static final String VALUE_ELEMENT_NAME = "Value"; + + public interface ValueCreator { + String getValue(); + } + + public static class ConstantValueCreator implements ValueCreator { + String value; + public ConstantValueCreator(String value) { + this.value = value; + } + @Override + public String getValue() { + return value; + } + } + + public static class DateValueCreator implements ValueCreator { + @Override + public String getValue() { + return dateFormatter.format(new Date()); + } + } + + public static class UuidValueCreator implements ValueCreator { + @Override + public String getValue() { + return base64RandomUuid(); + } + + } + + public static void ensureElements (Element element, Map childElements) { + for (Map.Entry entry: childElements.entrySet()) { + ensureElementContent(entry.getKey(), element, entry.getValue().getValue()); + } + } + + + @Nullable @Contract("_,_,true -> !null") + public static Element getElement(String elementPath, Element parentElement, boolean create) { + try { + Element result = (Element) xpath.evaluate(elementPath, parentElement, XPathConstants.NODE); + if (result == null && create) { + result = createHierarchically(elementPath, parentElement); + } + return result; + } catch (XPathExpressionException e) { + throw new IllegalStateException(e); + } + } + + public static List getElements (String elementPath, Element parentElement) { + try { + NodeList nodes = (NodeList) xpath.evaluate(elementPath, parentElement, XPathConstants.NODESET); + ArrayList result = new ArrayList<>(nodes.getLength()); + for (int i = 0; i < nodes.getLength(); i++) { + result.add(((Element) nodes.item(i))); + } + return result; + } catch (XPathExpressionException e) { + throw new IllegalStateException(e); + } + } + + public static int getElementsCount (String elementPath, Element parentElement) { + try { + NodeList nodes = (NodeList) xpath.evaluate(elementPath, parentElement, XPathConstants.NODESET); + return nodes.getLength(); + } catch (XPathExpressionException e) { + throw new IllegalStateException(e); + } + } + + public static Element newElement(String elementName, Element parentElement) { + Element newElement = parentElement.getOwnerDocument().createElement(elementName); + parentElement.appendChild(newElement); + return newElement; + } + + @Nullable + public static String getElementContent(String elementPath, Element parentElement) { + Element result = getElement(elementPath, parentElement, false); + return (result == null) ? null : result.getTextContent(); + } + + @NotNull + static String ensureElementContent(String elementPath, Element parentElement, @NotNull String value) { + Element result = getElement(elementPath, parentElement, false); + if (result == null) { + result = createHierarchically(elementPath, parentElement); + result.setTextContent(value); + } + return result.getTextContent(); + } + + @NotNull + public static Element setElementContent(String elementPath, Element parentElement, String value) { + Element result = getElement(elementPath, parentElement, true); + result.setTextContent(value); + return result; + } + + @NotNull + public static Element touchElement(String elementPath, Element parentElement) { + return setElementContent(elementPath, parentElement, dateFormatter.format(new Date())); + } + + private static Element createHierarchically(String elementPath, Element startElement) { + Element currentElement = startElement; + for (String elementName : elementPath.split("/")) { + try { + Element nextElement = (Element) xpath.evaluate(elementName, currentElement, XPathConstants.NODE); + if (nextElement == null) { + nextElement = (Element) currentElement.appendChild(currentElement.getOwnerDocument().createElement(elementName)); + } + currentElement = nextElement; + } catch (XPathExpressionException e) { + throw new IllegalStateException(e); + } + } + return currentElement; + } + + static String base64RandomUuid () { + return base64FromUuid(UUID.randomUUID()); + } + + static String base64FromUuid(UUID uuid) { + byte[] buffer = new byte[16]; + ByteBuffer b = ByteBuffer.wrap(buffer); + b.putLong(uuid.getMostSignificantBits()); + b.putLong(8, uuid.getLeastSignificantBits()); + return Base64.getEncoder().encodeToString(buffer); + } + + static String hexStringFromUuid(UUID uuid) { + byte[] buffer = new byte[16]; + ByteBuffer b = ByteBuffer.wrap(buffer); + b.putLong(uuid.getMostSignificantBits()); + b.putLong(8, uuid.getLeastSignificantBits()); + // round the houses for Android + return new String(Hex.encodeHex(buffer)); + } + + static String hexStringFromBase64(String base64) { + byte[] buffer = Base64.getDecoder().decode(base64); + return new String(Hex.encodeHex(buffer)); + } + + public static UUID uuidFromBase64(@NotNull String base64) { + byte[] buffer = Base64.getDecoder().decode(base64); + ByteBuffer b = ByteBuffer.wrap(buffer); + return new UUID(b.getLong(), b.getLong(8)); + } +} diff --git a/platform/credential-store/src/org/linguafranca/pwdb/kdbx/dom/DomSerializableDatabase.java b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/dom/DomSerializableDatabase.java new file mode 100644 index 000000000000..a23156ee99a1 --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/dom/DomSerializableDatabase.java @@ -0,0 +1,214 @@ +/* + * 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 org.linguafranca.pwdb.kdbx.dom; + +import org.linguafranca.pwdb.kdbx.Salsa20Encryption; +import org.linguafranca.pwdb.kdbx.SerializableDatabase; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.xml.bind.DatatypeConverter; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.*; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpressionException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Date; +import java.util.Locale; + +import static org.linguafranca.pwdb.kdbx.dom.DomHelper.*; + +/** + * This class is an XML DOM implementation of a KDBX database. The data is maintained as a DOM, + * despite the obvious inefficiency of doing do, in order to maintain transparency on loading and + * saving of elements and attributes this implementation knows nothing about. + * + *

Obviously, perhaps, if the database is added to, or under certain types of modification, + * those elements will be missing from a re-serialization. + * + * @author jo + */ +public class DomSerializableDatabase implements SerializableDatabase { + + private Document doc; + private Encryption encryption; + + private DomSerializableDatabase() {} + + public static DomSerializableDatabase createEmptyDatabase() throws IOException { + DomSerializableDatabase result = new DomSerializableDatabase(); + // read in the template KeePass XML database + result.load(result.getClass().getClassLoader().getResourceAsStream("base.kdbx.xml")); + try { + // replace all placeholder dates with now + String now = dateFormatter.format(new Date()); + NodeList list = (NodeList) xpath.evaluate("//*[contains(text(),'${creationDate}')]", result.doc.getDocumentElement(), XPathConstants.NODESET); + for (int i = 0; i < list.getLength(); i++) { + list.item(i).setTextContent(now); + } + // set the root group UUID + Node uuid = (Node) xpath.evaluate("//"+ UUID_ELEMENT_NAME, result.doc.getDocumentElement(), XPathConstants.NODE); + uuid.setTextContent(base64RandomUuid()); + } catch (XPathExpressionException e) { + throw new IllegalStateException(e); + } + result.setEncryption(new Salsa20Encryption(SecureRandom.getSeed(32))); + return result; + } + + @Override + public SerializableDatabase load(InputStream inputStream) throws IOException { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + try { + DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); + doc = dBuilder.parse(inputStream); + + // we need to decrypt all protected fields + // TODO we assume they are all strings, which is wrong + NodeList protectedContent = (NodeList) xpath.evaluate("//*[@Protected='True']", doc, XPathConstants.NODESET); + for (int i = 0; i < protectedContent.getLength(); i++){ + Element element = ((Element) protectedContent.item(i)); + String base64 = getElementContent(".", element); + byte[] encrypted = DatatypeConverter.parseBase64Binary(base64); + String decrypted = new String(encryption.decrypt(encrypted), "UTF-8"); + setElementContent(".", element, decrypted); + element.removeAttribute("Protected"); + } + + return this; + } catch (ParserConfigurationException e) { + throw new IllegalStateException("Instantiating Document Builder", e); + } catch (SAXException e) { + throw new IllegalStateException("Parsing exception", e); + } catch (XPathExpressionException e) { + throw new IllegalStateException("XPath Exception", e); + } + } + + @Override + public void save(OutputStream outputStream) { + Document copyDoc = (Document) doc.cloneNode(true); + try { + // check whether protection is required and if so mark the element with @Protected='True' + prepareProtection(copyDoc, "Title"); + prepareProtection(copyDoc, "UserName"); + prepareProtection(copyDoc, "Password"); + prepareProtection(copyDoc, "Notes"); + prepareProtection(copyDoc, "URL"); + + // encrypt and base64 every element marked as protected + NodeList protectedContent = (NodeList) xpath.evaluate("//*[@Protected='True']", copyDoc, XPathConstants.NODESET); + for (int i = 0; i < protectedContent.getLength(); i++){ + Element element = ((Element) protectedContent.item(i)); + String decrypted = getElementContent(".", element); + if (decrypted == null) { + decrypted = ""; + } + byte[] encrypted = encryption.encrypt(decrypted.getBytes(StandardCharsets.UTF_8)); + setElementContent(".", element, Base64.getEncoder().encodeToString(encrypted)); + } + + } catch (XPathExpressionException e) { + throw new IllegalStateException(e); + } + + Source xmlSource = new DOMSource(copyDoc); + Result outputTarget = new StreamResult(outputStream); + try { + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + transformer.transform(xmlSource, outputTarget); + } catch (TransformerException e) { + throw new IllegalStateException(e); + } + } + + @SuppressWarnings("unused") + public static String getStringFromDocument(Document doc) throws TransformerException { + StringWriter writer = new StringWriter(); + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.transform(new DOMSource(doc), new StreamResult(writer)); + return writer.toString(); + } + + private static final String protectQuery = "//Meta/MemoryProtection/Protect%s"; + private static final String pattern = "//String/Key[text()='%s']/following-sibling::Value"; + private static void prepareProtection(Document doc, String protect) throws XPathExpressionException { + // does this require encryption + String query = String.format(protectQuery, protect); + if (!((String) xpath.evaluate(query, doc, XPathConstants.STRING)).toLowerCase(Locale.ENGLISH).equals("true")) { + return; + } + // mark the field as Protected but don't actually encrypt yet, that comes later + String path = String.format(pattern, protect); + NodeList nodelist = (NodeList) xpath.evaluate(path, doc, XPathConstants.NODESET); + for (int i = 0; i < nodelist.getLength(); i++) { + Element element = (Element) nodelist.item(i); + element.setAttribute("Protected", "True"); + } + } + + @Override + public byte[] getHeaderHash() { + try { + String base64 = (String) xpath.evaluate("//HeaderHash", doc, XPathConstants.STRING); + return DatatypeConverter.parseBase64Binary(base64); + } catch (XPathExpressionException e) { + throw new IllegalStateException("Can't get header hash", e); + } + } + + @Override + public void setHeaderHash(byte[] hash) { + String base64String = DatatypeConverter.printBase64Binary(hash); + try { + ((Element) xpath.evaluate("//HeaderHash", doc, XPathConstants.NODE)).setTextContent(base64String); + } catch (XPathExpressionException e) { + throw new IllegalStateException("Can't set header hash", e); + } + } + + + @Override + public Encryption getEncryption() { + return encryption; + } + + @Override + public void setEncryption(Encryption encryption) { + this.encryption = encryption; + } + + public Document getDoc() { + return doc; + } +} diff --git a/platform/credential-store/src/org/linguafranca/security/Encryption.java b/platform/credential-store/src/org/linguafranca/security/Encryption.java new file mode 100644 index 000000000000..b90b6bc8aac7 --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/security/Encryption.java @@ -0,0 +1,134 @@ +/* + * 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 org.linguafranca.security; + +import org.bouncycastle.crypto.engines.AESEngine; +import org.bouncycastle.crypto.engines.AESFastEngine; +import org.bouncycastle.crypto.io.CipherInputStream; +import org.bouncycastle.crypto.io.CipherOutputStream; +import org.bouncycastle.crypto.modes.CBCBlockCipher; +import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.crypto.params.ParametersWithIV; + +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Encryption and decryption utilities.. + * + * @author jo + */ +public class Encryption { + + /** + * Gets a digest for a UTF-8 encoded string + * + * @param string the string + * @return a digest as a byte array + */ + @SuppressWarnings("unused") + public static byte[] getDigest(String string) { + return getDigest(string, "UTF-8"); + } + + /** + * Gets a digest for a string + * + * @param string the string + * @param encoding the encoding of the String + * @return a digest as a byte array + */ + public static byte[] getDigest(String string, String encoding) { + if (string == null || string.length() == 0) + throw new IllegalArgumentException("String cannot be null or empty"); + + if (encoding == null || encoding.length() == 0) + throw new IllegalArgumentException("Encoding cannot be null or empty"); + + MessageDigest md = getMessageDigestInstance(); + + try { + byte[] bytes = string.getBytes(encoding); + md.update(bytes, 0, bytes.length); + return md.digest(); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(encoding + " is not supported"); + } + } + + /** + * Gets a SHA-256 message digest instance + * + * @return A MessageDigest + */ + public static MessageDigest getMessageDigestInstance() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not supported"); + } + } + + /** + * Create a final key from the parameters passed + */ + public static byte[] getFinalKeyDigest(byte[] key, byte[] masterSeed, byte[] transformSeed, long transformRounds) { + + AESEngine engine = new AESEngine(); + engine.init(true, new KeyParameter(transformSeed)); + + // copy input key + byte[] transformedKey = new byte[key.length]; + System.arraycopy(key, 0, transformedKey, 0, transformedKey.length); + + // transform rounds times + for (long rounds = 0; rounds < transformRounds; rounds++) { + engine.processBlock(transformedKey, 0, transformedKey, 0); + engine.processBlock(transformedKey, 16, transformedKey, 16); + } + + MessageDigest md = getMessageDigestInstance(); + byte[] transformedKeyDigest = md.digest(transformedKey); + + md.update(masterSeed); + return md.digest(transformedKeyDigest); + } + + /** + * Create a decrypted input stream from an encrypted one + */ + public static InputStream getDecryptedInputStream (InputStream encryptedInputStream, byte[] keyData, byte[] ivData) { + final ParametersWithIV keyAndIV = new ParametersWithIV(new KeyParameter(keyData), ivData); + PaddedBufferedBlockCipher pbbc = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESFastEngine())); + pbbc.init(false, keyAndIV); + return new CipherInputStream(encryptedInputStream, pbbc); + } + + /** + * Create an encrypted output stream from an unencrypted output stream + */ + public static OutputStream getEncryptedOutputStream (OutputStream decryptedOutputStream, byte[] keyData, byte[] ivData) { + final ParametersWithIV keyAndIV = new ParametersWithIV(new KeyParameter(keyData), ivData); + PaddedBufferedBlockCipher pbbc = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESFastEngine())); + pbbc.init(true, keyAndIV); + return new CipherOutputStream(decryptedOutputStream, pbbc); + } +} diff --git a/platform/credential-store/test/FileCredentialStoreTest.kt b/platform/credential-store/test/FileCredentialStoreTest.kt index 26bcd981d4fb..95f886f4135f 100644 --- a/platform/credential-store/test/FileCredentialStoreTest.kt +++ b/platform/credential-store/test/FileCredentialStoreTest.kt @@ -25,12 +25,14 @@ import java.util.* private const val TEST_SERVICE_NAME = "IntelliJ Platform Test" +// part of specific tests in the IcsCredentialTest class FileCredentialStoreTest { + // we don't use in memory fs to check real file io private val tempDirManager = TemporaryDirectory() - @Rule - @JvmField - val ruleChain = RuleChain(tempDirManager) + @Rule + @JvmField + val ruleChain = RuleChain(tempDirManager) @Test fun many() { @@ -49,13 +51,11 @@ class FileCredentialStoreTest { provider.deleteFileStorage() - val pdbFile = baseDir.resolve("pdb") + val pdbFile = baseDir.resolve("c.kdbx") val pdbPwdFile = baseDir.resolve("pdb.pwd") - val pdbPwdTmpFile = baseDir.resolve("pdb.pwd.tmp") assertThat(pdbFile).doesNotExist() assertThat(pdbPwdFile).doesNotExist() - assertThat(pdbPwdTmpFile).doesNotExist() } @Test @@ -71,15 +71,13 @@ class FileCredentialStoreTest { assertThat(baseDir).doesNotExist() - val pdbFile = baseDir.resolve("pdb") + val pdbFile = baseDir.resolve("c.kdbx") val pdbPwdFile = baseDir.resolve("pdb.pwd") - val pdbPwdTmpFile = baseDir.resolve("pdb.pwd.tmp") provider.save() assertThat(pdbFile).isRegularFile() assertThat(pdbPwdFile).isRegularFile() - assertThat(pdbPwdTmpFile).doesNotExist() val amAttributes = CredentialAttributes(TEST_SERVICE_NAME, "am") provider.setPassword(amAttributes, "pass2") @@ -94,7 +92,6 @@ class FileCredentialStoreTest { assertThat(pdbFile).isRegularFile() assertThat(pdbPwdFile).isRegularFile() - assertThat(pdbPwdTmpFile).doesNotExist() provider = FileCredentialStore(baseDirectory = baseDir) @@ -106,14 +103,9 @@ class FileCredentialStoreTest { provider.save() - assertThat(pdbFile).doesNotExist() - assertThat(pdbPwdFile).doesNotExist() - assertThat(pdbPwdTmpFile).doesNotExist() - provider.deleteFileStorage() assertThat(pdbFile).doesNotExist() assertThat(pdbPwdFile).doesNotExist() - assertThat(pdbPwdTmpFile).doesNotExist() } } diff --git a/platform/platform-api/src/com/intellij/ide/passwordSafe/CredentialAttributes.kt b/platform/platform-api/src/com/intellij/credentialStore/CredentialAttributes.kt similarity index 74% rename from platform/platform-api/src/com/intellij/ide/passwordSafe/CredentialAttributes.kt rename to platform/platform-api/src/com/intellij/credentialStore/CredentialAttributes.kt index 1f25e7d9e514..dfbdcd8f3bf9 100644 --- a/platform/platform-api/src/com/intellij/ide/passwordSafe/CredentialAttributes.kt +++ b/platform/platform-api/src/com/intellij/credentialStore/CredentialAttributes.kt @@ -32,14 +32,4 @@ class Credentials(user: String?, val password: String?) { override fun hashCode() = (user?.hashCode() ?: 0) * 37 + (password?.hashCode() ?: 0) } -interface CredentialStore { - fun get(attributes: CredentialAttributes): Credentials? - - fun getPassword(attributes: CredentialAttributes) = get(attributes)?.password - - fun set(attributes: CredentialAttributes, credentials: Credentials?) - - fun setPassword(attributes: CredentialAttributes, password: String?) { - set(attributes, Credentials(attributes.accountName, password)) - } -} +fun CredentialAttributes(requestor: Class<*>, accountName: String) = CredentialAttributes(requestor.name, accountName) \ No newline at end of file diff --git a/platform/platform-api/src/com/intellij/credentialStore/CredentialStore.java b/platform/platform-api/src/com/intellij/credentialStore/CredentialStore.java new file mode 100644 index 000000000000..22126f088179 --- /dev/null +++ b/platform/platform-api/src/com/intellij/credentialStore/CredentialStore.java @@ -0,0 +1,36 @@ +/* + * Copyright 2000-2016 JetBrains s.r.o. + * + * 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; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface CredentialStore { + @Nullable + Credentials get(@NotNull CredentialAttributes attributes); + + @Nullable + default String getPassword(@NotNull CredentialAttributes attributes) { + Credentials credentials = get(attributes); + return credentials == null ? null : credentials.getPassword(); + } + + void set(@NotNull CredentialAttributes attributes, @Nullable Credentials credentials); + + default void setPassword(@NotNull CredentialAttributes attributes, @Nullable String password) { + set(attributes, password == null ? null : new Credentials(attributes.getAccountName(), password)); + } +} diff --git a/platform/platform-api/src/com/intellij/ide/passwordSafe/PasswordStorage.java b/platform/platform-api/src/com/intellij/ide/passwordSafe/PasswordStorage.java index 919cf7bd7d3b..f89a348b128a 100644 --- a/platform/platform-api/src/com/intellij/ide/passwordSafe/PasswordStorage.java +++ b/platform/platform-api/src/com/intellij/ide/passwordSafe/PasswordStorage.java @@ -15,23 +15,24 @@ */ package com.intellij.ide.passwordSafe; -import com.intellij.credentialStore.CredentialAttributes; import com.intellij.credentialStore.CredentialStore; import com.intellij.credentialStore.Credentials; import com.intellij.openapi.project.Project; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import static com.intellij.credentialStore.CredentialAttributesKt.CredentialAttributes; + public interface PasswordStorage extends CredentialStore { @Deprecated @Nullable default String getPassword(@NotNull Class requestor, @NotNull String accountName) { - return getPassword(createAttributes(requestor, accountName)); + return getPassword(CredentialAttributes(requestor, accountName)); } @Deprecated default void setPassword(@NotNull Class requestor, @NotNull String accountName, @Nullable String value) { - set(createAttributes(requestor, accountName), new Credentials(accountName, value)); + set(CredentialAttributes(requestor, accountName), new Credentials(accountName, value)); } /** @@ -59,9 +60,4 @@ public interface PasswordStorage extends CredentialStore { //noinspection deprecation return getPassword(requestor, key); } - - @NotNull - static CredentialAttributes createAttributes(@NotNull Class requestor, @NotNull String accountName) { - return new CredentialAttributes(requestor.getName(), accountName); - } } diff --git a/platform/platform-impl/src/com/intellij/ide/passwordSafe/impl/providers/BasePasswordSafeProvider.java b/platform/platform-impl/src/com/intellij/ide/passwordSafe/impl/providers/BasePasswordSafeProvider.java index 38d9ecd315a6..62288804e0c7 100644 --- a/platform/platform-impl/src/com/intellij/ide/passwordSafe/impl/providers/BasePasswordSafeProvider.java +++ b/platform/platform-impl/src/com/intellij/ide/passwordSafe/impl/providers/BasePasswordSafeProvider.java @@ -51,16 +51,4 @@ public abstract class BasePasswordSafeProvider implements PasswordStorage { } protected abstract void storeEncryptedPassword(byte[] key, byte[] encryptedPassword); - - @Nullable - @Override - public final String getPassword(@NotNull CredentialAttributes attributes) { - Credentials credentials = get(attributes); - return credentials == null ? null : credentials.getPassword(); - } - - @Override - public final void setPassword(@NotNull CredentialAttributes attributes, @Nullable String password) { - set(attributes, new Credentials(attributes.getAccountName(), password)); - } } diff --git a/platform/platform-impl/src/com/intellij/util/path.kt b/platform/platform-impl/src/com/intellij/util/path.kt index 162bec02a811..ea60dc3edcb2 100644 --- a/platform/platform-impl/src/com/intellij/util/path.kt +++ b/platform/platform-impl/src/com/intellij/util/path.kt @@ -151,6 +151,19 @@ fun Path.writeSafe(data: ByteArray, offset: Int = 0, size: Int = data.size): Pat return this } +fun Path.writeSafe(outConsumer: (OutputStream) -> Unit): Path { + val tempFile = parent.resolve("${fileName}.${UUID.randomUUID()}.tmp") + tempFile.outputStream().use(outConsumer) + try { + Files.move(tempFile, this, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } + catch (e: IOException) { + LOG.warn(e) + FileUtil.rename(tempFile.toFile(), this.toFile()) + } + return this +} + fun Path.write(data: String): Path { parent?.createDirectories() diff --git a/plugins/settings-repository/settings-repository.iml b/plugins/settings-repository/settings-repository.iml index a60fcc1535c4..480c5a0ae562 100644 --- a/plugins/settings-repository/settings-repository.iml +++ b/plugins/settings-repository/settings-repository.iml @@ -51,5 +51,6 @@ + \ No newline at end of file diff --git a/plugins/settings-repository/src/IcsCredentialsStore.kt b/plugins/settings-repository/src/IcsCredentialsStore.kt new file mode 100644 index 000000000000..7c56d661c645 --- /dev/null +++ b/plugins/settings-repository/src/IcsCredentialsStore.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2000-2016 JetBrains s.r.o. + * + * 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 org.jetbrains.settingsRepository + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.ide.passwordSafe.PasswordSafe + +/** + * Note - in case of SSH, our added password will not be used until ssh-agent will not be restarted (simply execute "killall ssh-agent"). + * Also, if you remove password from keychain, ssh-agent will continue to use cached password. + */ +class IcsCredentialsStore() { + fun get(host: String?, sshKeyFile: String?, accountName: String?) = CredentialAttributes(host, sshKeyFile, accountName)?.let { PasswordSafe.getInstance().get(it) } + + fun set(host: String?, sshKeyFile: String?, credentials: Credentials?) { + CredentialAttributes(host, sshKeyFile, credentials?.user)?.let { PasswordSafe.getInstance().set(it, credentials) } + } +} + +private fun CredentialAttributes(host: String?, sshKeyFile: String?, accountName: String?): CredentialAttributes? { + if (sshKeyFile == null) { + return CredentialAttributes("IntelliJ Platform Settings Repository — $host", accountName) + } + else { + return CredentialAttributes("SSH", sshKeyFile) + } +} \ No newline at end of file diff --git a/plugins/settings-repository/src/IcsManager.kt b/plugins/settings-repository/src/IcsManager.kt index 16ef9b610b7c..b7432d76b2ba 100644 --- a/plugins/settings-repository/src/IcsManager.kt +++ b/plugins/settings-repository/src/IcsManager.kt @@ -17,7 +17,6 @@ package org.jetbrains.settingsRepository import com.intellij.configurationStore.StateStorageManagerImpl import com.intellij.configurationStore.StreamProvider -import com.intellij.credentialStore.macOs.isMacOsCredentialStoreSupported import com.intellij.ide.ApplicationLoadListener import com.intellij.openapi.application.Application import com.intellij.openapi.application.ApplicationManager @@ -30,7 +29,6 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.project.impl.ProjectLifecycleListener import com.intellij.openapi.util.io.FileUtil import com.intellij.util.SingleAlarm -import com.intellij.util.SystemProperties import com.intellij.util.exists import com.intellij.util.move import org.jetbrains.settingsRepository.git.GitRepositoryManager @@ -50,14 +48,7 @@ val icsManager by lazy(LazyThreadSafetyMode.NONE) { } class IcsManager(dir: Path) { - val credentialsStore = lazy { - if (isMacOsCredentialStoreSupported && SystemProperties.getBooleanProperty("use.mac.keychain", true)) { - catchAndLog { - return@lazy IcsMacOsCredentialsStore("IntelliJ Platform Settings Repository") - } - } - IcsCredentialsStoreWrapper() - } + val credentialsStore = lazy { IcsCredentialsStore() } val settingsFile: Path = dir.resolve("config.json") diff --git a/plugins/settings-repository/src/git/JGitCredentialsProvider.kt b/plugins/settings-repository/src/git/JGitCredentialsProvider.kt index 863d720c0e08..42d9b65a829a 100644 --- a/plugins/settings-repository/src/git/JGitCredentialsProvider.kt +++ b/plugins/settings-repository/src/git/JGitCredentialsProvider.kt @@ -15,6 +15,8 @@ */ package org.jetbrains.settingsRepository.git +import com.google.common.cache.CacheBuilder +import com.google.common.cache.CacheLoader import com.intellij.credentialStore.Credentials import com.intellij.credentialStore.isFulfilled import com.intellij.credentialStore.macOs.isMacOsCredentialStoreSupported @@ -29,9 +31,14 @@ import org.eclipse.jgit.transport.URIish import org.jetbrains.settingsRepository.IcsCredentialsStore import org.jetbrains.settingsRepository.catchAndLog import org.jetbrains.settingsRepository.showAuthenticationForm +import java.util.concurrent.TimeUnit class JGitCredentialsProvider(private val credentialsStore: Lazy, private val repository: Repository) : CredentialsProvider() { - private var credentialsFromGit: Credentials? = null + private val credentialsFromGit = CacheBuilder.newBuilder() + .expireAfterAccess(5, TimeUnit.MINUTES) + .build(object : CacheLoader() { + override fun load(it: URIish) = getCredentialsUsingGit(it, repository) ?: Credentials(null, null) + }) override fun isInteractive() = true @@ -82,52 +89,29 @@ class JGitCredentialsProvider(private val credentialsStore: Lazy() - - override fun get(host: String?, sshKeyFile: String?): Credentials? { - if (host == null) { - return null - } - - val accountName = sshKeyFile ?: host - var credentials = accountToCredentials.get(accountName) - if (credentials != null) { - return credentials - } - - val data = findGenericPassword(getServiceName(sshKeyFile), accountName) ?: return null - if (sshKeyFile == null) { - val separatorIndex = data.indexOf('@') - if (separatorIndex > 0) { - val username = PasswordUtil.decodePassword(data.substring(0, separatorIndex)) - val password = PasswordUtil.decodePassword(data.substring(separatorIndex + 1)) - credentials = Credentials(username, password) - } - else { - return null - } - } - else { - credentials = Credentials(sshKeyFile, data) - } - - accountToCredentials.set(accountName, credentials) - return credentials - } - - private fun getServiceName(sshKeyFile: String?) = if (sshKeyFile == null) serviceName else SSH - - /** - * Note - in case of SSH, our added password will not be used until ssh-agent will not be restarted (simply execute "killall ssh-agent"). - * Also, if you remove password from keychain, ssh-agent will continue to use cached password. - */ - override fun set(host: String?, credentials: Credentials, sshKeyFile: String?) { - val accountName: String = sshKeyFile ?: host!! - val oldCredentials = accountToCredentials.put(accountName, credentials) - if (credentials == oldCredentials) { - return - } - - val data = if (sshKeyFile == null) credentials.toString() else credentials.password!! - saveGenericPassword(getServiceName(sshKeyFile), accountName, data.toByteArray()) - } - - override fun reset(host: String) { - if (accountToCredentials.remove(host) != null) { - deleteGenericPassword(serviceName, host) - } - } -} diff --git a/plugins/settings-repository/testSrc/IcsCredentialTest.kt b/plugins/settings-repository/testSrc/IcsCredentialTest.kt index d9f7cf2895f3..3650313ae55a 100644 --- a/plugins/settings-repository/testSrc/IcsCredentialTest.kt +++ b/plugins/settings-repository/testSrc/IcsCredentialTest.kt @@ -22,11 +22,11 @@ internal class IcsCredentialTest { return JGitCredentialsProvider(lazyOf(credentialsStore), FileRepositoryBuilder().setBare().setGitDir(File("/tmp/fake")).build()) } - private fun createFileStore() = IcsCredentialsStoreWrapper() + private fun createStore() = IcsCredentialsStore() @Test fun explicitSpecifiedInURL() { - val credentialsStore = createFileStore() + val credentialsStore = createStore() val username = CredentialItem.Username() val password = CredentialItem.Password() val uri = URIish("https://develar:bike@github.com/develar/settings-repository.git") @@ -34,13 +34,13 @@ internal class IcsCredentialTest { assertThat(username.value).isEqualTo("develar") assertThat(String(password.value!!)).isEqualTo("bike") // ensure that credentials store was not used - assertThat(credentialsStore.get(uri.host)).isNull() + assertThat(credentialsStore.get(uri.host, null, null)).isNull() } @Test fun gitCredentialHelper() { - val credentialStore = createFileStore() - credentialStore.set("bitbucket.org", Credentials("develar", "bike")) + val credentialStore = createStore() + credentialStore.set("bitbucket.org", null, Credentials("develar", "bike")) val username = CredentialItem.Username() val password = CredentialItem.Password() @@ -49,4 +49,17 @@ internal class IcsCredentialTest { assertThat(username.value).isEqualTo("develar") assertThat(String(password.value!!)).isNotEmpty() } + + @Test + fun userByServiceName() { + val credentialStore = createStore() + credentialStore.set("bitbucket.org", null, Credentials("develar", "bike")) + + val username = CredentialItem.Username() + val password = CredentialItem.Password() + val uri = URIish("https://bitbucket.org/develar/test-ics.git") + assertThat(createProvider(credentialStore).get(uri, username, password)).isTrue() + assertThat(username.value).isEqualTo("develar") + assertThat(String(password.value!!)).isNotEmpty() + } }