PS API — customizable service name, part 2

This commit is contained in:
Vladimir Krivosheev
2016-08-23 15:07:58 +02:00
parent 7533bbffd3
commit 421b48dbd0
38 changed files with 2662 additions and 309 deletions
+11
View File
@@ -0,0 +1,11 @@
<component name="libraryTable">
<library name="bouncy-castle-provider">
<CLASSES>
<root url="jar://$PROJECT_DIR$/lib/bcprov-jdk15on-155.jar!/" />
</CLASSES>
<JAVADOC />
<SOURCES>
<root url="jar://$PROJECT_DIR$/lib/src/bcprov.zip!/" />
</SOURCES>
</library>
</component>
@@ -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",
Binary file not shown.
+1
View File
@@ -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
Binary file not shown.
@@ -5,6 +5,7 @@
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
@@ -15,5 +16,8 @@
<orderEntry type="library" scope="TEST" name="assertJ" level="project" />
<orderEntry type="library" scope="TEST" name="JUnit4" level="project" />
<orderEntry type="module" module-name="testFramework" scope="TEST" />
<orderEntry type="library" name="bouncy-castle" level="project" />
<orderEntry type="library" name="Guava" level="project" />
<orderEntry type="library" name="bouncy-castle-provider" level="project" />
</component>
</module>
@@ -0,0 +1,59 @@
<KeePassFile>
<Meta>
<Generator>Keepass-Java-2</Generator>
<HeaderHash></HeaderHash>
<DatabaseName>New Database</DatabaseName>
<DatabaseNameChanged>${creationDate}</DatabaseNameChanged>
<DatabaseDescription>Empty Database</DatabaseDescription>
<DatabaseDescriptionChanged>${creationDate}</DatabaseDescriptionChanged>
<DefaultUserName/>
<DefaultUserNameChanged>${creationDate}</DefaultUserNameChanged>
<MaintenanceHistoryDays>365</MaintenanceHistoryDays>
<Color/>
<MasterKeyChanged>${creationDate}</MasterKeyChanged>
<MasterKeyChangeRec>-1</MasterKeyChangeRec>
<MasterKeyChangeForce>-1</MasterKeyChangeForce>
<MemoryProtection>
<ProtectTitle>False</ProtectTitle>
<ProtectUserName>False</ProtectUserName>
<ProtectPassword>True</ProtectPassword>
<ProtectURL>False</ProtectURL>
<ProtectNotes>False</ProtectNotes>
</MemoryProtection>
<CustomIcons/>
<RecycleBinEnabled>True</RecycleBinEnabled>
<RecycleBinUUID>AAAAAAAAAAAAAAAAAAAAAA==</RecycleBinUUID>
<RecycleBinChanged>${creationDate}</RecycleBinChanged>
<EntryTemplatesGroup>AAAAAAAAAAAAAAAAAAAAAA==</EntryTemplatesGroup>
<EntryTemplatesGroupChanged>${creationDate}</EntryTemplatesGroupChanged>
<LastSelectedGroup>AAAAAAAAAAAAAAAAAAAAAA==</LastSelectedGroup>
<LastTopVisibleGroup>AAAAAAAAAAAAAAAAAAAAAA==</LastTopVisibleGroup>
<HistoryMaxItems>10</HistoryMaxItems>
<HistoryMaxSize>6291456</HistoryMaxSize>
<Binaries/>
<CustomData/>
</Meta>
<Root>
<Group>
<UUID></UUID>
<Name>Root</Name>
<Notes/>
<IconID>48</IconID>
<Times>
<LastModificationTime>${creationDate}</LastModificationTime>
<CreationTime>${creationDate}</CreationTime>
<LastAccessTime>${creationDate}</LastAccessTime>
<ExpiryTime>${creationDate}</ExpiryTime>
<Expires>False</Expires>
<UsageCount>0</UsageCount>
<LocationChanged>${creationDate}</LocationChanged>
</Times>
<IsExpanded>True</IsExpanded>
<DefaultAutoTypeSequence/>
<EnableAutoType>True</EnableAutoType>
<EnableSearching>True</EnableSearching>
<LastTopVisibleEntry>AAAAAAAAAAAAAAAAAAAAAA==</LastTopVisibleEntry>
</Group>
<DeletedObjects/>
</Root>
</KeePassFile>
@@ -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<CredentialAttributes, Credentials>? = null, baseDirectory: Path = Paths.get(PathManager.getConfigPath()), var memoryOnly: Boolean = false) : PasswordStorage, CredentialStore {
private val db = ContainerUtil.newConcurrentMap<CredentialAttributes, Credentials>()
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<CredentialAttributes, Credent
}
finally {
masterKeyStorage.set(null)
encryptionSupport = null
}
}
fun clear() {
db.clear()
db.rootGroup.removeGroup(GROUP_NAME)
needToSave.set(true)
}
@@ -135,7 +111,8 @@ internal class FileCredentialStore(keyToValue: Map<CredentialAttributes, Credent
val password = super<PasswordStorage>.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<CredentialAttributes, Credent
}
override fun get(attributes: CredentialAttributes): Credentials? {
if (attributes.accountName == null) {
for ((k, v) in db) {
if (k.serviceName == attributes.serviceName) {
return Credentials(attributes.serviceName, v.password)
}
}
}
return db.get(attributes)
val group = db.rootGroup.getGroup(GROUP_NAME) ?: return null
val entry = group.getEntry { it.title == attributes.serviceName && (it.userName == attributes.accountName || attributes.accountName == null) } ?: return null
return Credentials(entry.userName, entry.password)
}
override fun set(attributes: CredentialAttributes, credentials: Credentials?) {
val group = db.rootGroup.getOrCreateGroup(GROUP_NAME)
if (credentials == null) {
if (db.remove(attributes) != null) {
needToSave.set(true)
}
group.removeEntry(attributes.serviceName, attributes.accountName)
}
else if (db.put(attributes, credentials) != credentials) {
else {
group.getOrCreateEntry(attributes.serviceName, attributes.accountName).password = credentials.password
}
if (db.isDirty) {
needToSave.set(true)
}
}
fun copyTo(store: PasswordStorage) {
copyTo(db, store)
val group = db.rootGroup.getGroup(GROUP_NAME) ?: return
for (entry in group.entries) {
val title = entry.title
if (title != null) {
store.set(CredentialAttributes(title, entry.userName), Credentials(entry.userName, entry.password))
}
}
}
}
@@ -15,7 +15,6 @@
*/
package com.intellij.credentialStore
import com.intellij.ide.passwordSafe.PasswordStorage
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.util.text.StringUtil
import java.security.MessageDigest
@@ -29,8 +28,6 @@ internal fun toOldKeyAsIdentity(hash: ByteArray) = CredentialAttributes("Intelli
internal fun toOldKey(requestor: Class<*>, 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? {
+181
View File
@@ -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<String>
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
}
@@ -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<String, DomHelper.ValueCreator> = object : HashMap<String, DomHelper.ValueCreator>() {
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<String>
get() {
val result = ArrayList<String>()
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)
}
@@ -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<String, ValueCreator> = 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<Entry>
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<KdbxGroup>()
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
}
}
@@ -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
}
@@ -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
}
@@ -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)
}
}
}
@@ -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
*
* <p>A Hashed block consists of:
*
* <ol>
* <li>A 4 byte block sequence number, increments from 0
* <li>A 32 byte MD5 hash of the content
* <li>A 4 byte length field
* <li>Content
* </ol>
*
* <p>The stream of blocks is terminated with a 0 length 0 hash block.
*
* <p>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;
}
}
}
@@ -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.
*
* <p>A Hashed block consists of:
*
* <ol>
* <li>A 4 byte block sequence number, increments from 0
* <li>A 32 byte MD5 hash of the content
* <li>A 4 byte length field
* <li>Content
* </ol>
*
* <p>The stream of blocks is terminated with a 0 length 0 hash block.
*
* <p>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});
}
}
@@ -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.
*
* <p>It is a factory for encryption and decryption streams and contains a hash of its own serialization.
*
* <p>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;
}
}
@@ -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.
* <p/>
* A KDBX file is little-endian and consists of the following:
* <ol>
* <li>An unencrypted portion</li>
* <ol>
* <li>8 bytes Magic number</li>
* <li>4 bytes version</li>
* <li>A header containing details of the encryption of the remainder of the file</li>
* <p>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.</p>
* </ol>
* <li>An encrypted portion</li>
* <ol>
* <li>A sequence of bytes contained in the header. If they don't match, decryption has not worked.</li>
* <li>A payload serialized in Hashed Block format.</li>
* <p>The content of this payload is expected to be a Keepass Database in XML format.</p>
* </ol>
* </ol>
* <p/>
* The methods in this class provide support for serializing and deserializing plain text payload content
* to and from the above format.
* <p/>
* @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;
}
}
@@ -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.
*
* <p>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.
*
* <p>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;
}
}
@@ -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.
*
* <p>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).
*
* <p>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);
}
@@ -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<String, ValueCreator> childElements) {
for (Map.Entry<String, ValueCreator> 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<Element> getElements (String elementPath, Element parentElement) {
try {
NodeList nodes = (NodeList) xpath.evaluate(elementPath, parentElement, XPathConstants.NODESET);
ArrayList<Element> 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));
}
}
@@ -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.
*
* <p>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;
}
}
@@ -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);
}
}
@@ -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()
}
}
@@ -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)
@@ -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));
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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()
@@ -51,5 +51,6 @@
<orderEntry type="module" module-name="configuration-store-impl" />
<orderEntry type="library" name="miglayout-swing" level="project" />
<orderEntry type="module" module-name="credential-store" />
<orderEntry type="library" name="Guava" level="project" />
</component>
</module>
@@ -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)
}
}
+1 -10
View File
@@ -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")
@@ -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<IcsCredentialsStore>, private val repository: Repository) : CredentialsProvider() {
private var credentialsFromGit: Credentials? = null
private val credentialsFromGit = CacheBuilder.newBuilder()
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(object : CacheLoader<URIish, Credentials>() {
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<IcsCredentialsS
}
private fun doGet(uri: URIish, userNameItem: CredentialItem.Username?, passwordItem: CredentialItem?, sshKeyFile: String?): Boolean {
var credentials: Credentials?
var credentials: Credentials? = null
// SSH URL git@github.com:develar/_idea_settings.git, so, username will be "git", we ignore it because in case of SSH credentials account name equals to key filename, but not to username
val userFromUri: String? = if (sshKeyFile == null) uri.user.nullize() else null
val passwordFromUri: String? = uri.pass.nullize()
var saveCredentialsToStore = false
if (userFromUri != null && passwordFromUri != null) {
credentials = Credentials(userFromUri, passwordFromUri)
}
else {
// we open password protected SSH key file using OS X keychain - "git credentials" is pointless in this case
if (sshKeyFile == null || !isMacOsCredentialStoreSupported) {
if (credentialsFromGit == null) {
credentialsFromGit = getCredentialsUsingGit(uri, repository)
}
credentials = credentialsFromGit
}
else {
credentials = null
}
if (credentials == null) {
catchAndLog {
credentials = credentialsStore.value.get(uri.host, sshKeyFile)
}
saveCredentialsToStore = true
if (userFromUri != null) {
// username is in url - read password only if it is for the same user
if (userFromUri != credentials?.user) {
credentials = Credentials(userFromUri, passwordFromUri)
}
else if (passwordFromUri != null && passwordFromUri != credentials?.password) {
credentials = Credentials(userFromUri, passwordFromUri)
}
catchAndLog {
credentials = credentialsStore.value.get(uri.host, sshKeyFile, userFromUri)
// we open password protected SSH key file using OS X keychain - "git credentials" is pointless in this case
if (!credentials.isFulfilled() && (sshKeyFile == null || !isMacOsCredentialStoreSupported)) {
credentials = credentialsFromGit.get(uri)
}
}
}
if (!credentials.isFulfilled()) {
credentials = showAuthenticationForm(credentials, uri.toStringWithoutCredentials(), uri.host, uri.path, sshKeyFile)
}
if (saveCredentialsToStore && credentials.isFulfilled()) {
credentialsStore.value.set(uri.host, credentials!!, sshKeyFile)
if (credentials.isFulfilled()) {
credentialsStore.value.set(uri.host, sshKeyFile, credentials)
}
}
userNameItem?.value = credentials?.user
@@ -144,12 +128,13 @@ class JGitCredentialsProvider(private val credentialsStore: Lazy<IcsCredentialsS
}
override fun reset(uri: URIish) {
credentialsFromGit = null
credentialsStore.value.reset(uri.host!!)
credentialsFromGit.invalidate(uri)
credentialsFromGit.cleanUp()
credentialsStore.value.set(uri.host!!, null, null)
}
}
fun URIish.toStringWithoutCredentials(): String {
private fun URIish.toStringWithoutCredentials(): String {
val r = StringBuilder()
if (scheme != null) {
r.append(scheme)
@@ -18,7 +18,6 @@ package org.jetbrains.settingsRepository.git
import com.intellij.credentialStore.Credentials
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.process.ProcessNotCreatedException
import com.intellij.openapi.util.text.StringUtil
import org.eclipse.jgit.lib.Repository
import org.eclipse.jgit.transport.URIish
import org.jetbrains.settingsRepository.LOG
@@ -26,7 +25,7 @@ import org.jetbrains.settingsRepository.LOG
private var canUseGitExe = true
// https://www.kernel.org/pub/software/scm/git/docs/git-credential.html
fun getCredentialsUsingGit(uri: URIish, repository: Repository): Credentials? {
internal fun getCredentialsUsingGit(uri: URIish, repository: Repository): Credentials? {
if (!canUseGitExe || repository.config.getSubsections("credential").isEmpty()) {
return null
}
@@ -47,7 +46,7 @@ fun getCredentialsUsingGit(uri: URIish, repository: Repository): Credentials? {
writer.write("\n\n")
writer.close()
val reader = process.inputStream.reader().buffered()
val reader = process.inputStream.bufferedReader()
var username: String? = null
var password: String? = null
while (true) {
@@ -68,7 +67,7 @@ fun getCredentialsUsingGit(uri: URIish, repository: Repository): Credentials? {
reader.close()
val errorText = process.errorStream.reader().readText()
if (!StringUtil.isEmpty(errorText)) {
if (errorText.isNotEmpty()) {
LOG.warn(errorText)
}
return if (username == null && password == null) null else Credentials(username, password)
@@ -1,49 +0,0 @@
/*
* 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
interface IcsCredentialsStore {
fun get(host: String?, sshKeyFile: String? = null): Credentials?
fun set(host: String?, credentials: Credentials, sshKeyFile: String? = null)
fun reset(host: String)
}
class IcsCredentialsStoreWrapper() : IcsCredentialsStore {
override fun get(host: String?, sshKeyFile: String?) = getAttributes(host, sshKeyFile)?.let { PasswordSafe.getInstance().get(it) }
private fun getAttributes(host: String?, sshKeyFile: String?): CredentialAttributes? {
if (sshKeyFile == null) {
return CredentialAttributes("IntelliJ Platform Settings Repository — $host")
}
else {
return CredentialAttributes("SSH", sshKeyFile)
}
}
override fun reset(host: String) {
PasswordSafe.getInstance().setPassword(CredentialAttributes("IntelliJ Platform Settings Repository — $host"), null)
}
override fun set(host: String?, credentials: Credentials, sshKeyFile: String?) {
getAttributes(host, sshKeyFile)?.let { PasswordSafe.getInstance().set(it, credentials) }
}
}
@@ -1,87 +0,0 @@
/*
* 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.Credentials
import com.intellij.credentialStore.macOs.deleteGenericPassword
import com.intellij.credentialStore.macOs.findGenericPassword
import com.intellij.credentialStore.macOs.saveGenericPassword
import com.intellij.openapi.util.PasswordUtil
import gnu.trove.THashMap
internal class IcsMacOsCredentialsStore(serviceName: String) : IcsCredentialsStore {
private val serviceName = serviceName.toByteArray()
companion object {
val SSH = "SSH".toByteArray()
}
private val accountToCredentials = THashMap<String, Credentials>()
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)
}
}
}
@@ -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()
}
}