mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
PS API — customizable service name, part 2
This commit is contained in:
+11
@@ -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.
@@ -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? {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+183
@@ -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));
|
||||
}
|
||||
}
|
||||
+214
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user