A Hashed block consists of: + * + *
The stream of blocks is terminated with a 0 length 0 hash block. + * + *
Originally developed for KeePass. A KeePass hash block + * stream is little endian, i.e. the sequence + * number and length fields are low order byte first. + * + * @author Jo + */ +public class HashedBlockInputStream extends InputStream { + + private static MessageDigest md5; + static { + try { + md5 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + private static final int HASH_SIZE = 32; + private static final byte[] ZERO_HASH = new byte[HASH_SIZE]; + + private long expectedSequenceNumber = 0; + private boolean littleEndian = false; + private boolean done = false; + private InputStream inputStream; + private ByteArrayInputStream blockInputStream = new ByteArrayInputStream(new byte[0]); + + /** + * Create a Big Endian Hash Block Input Stream + * @param inputStream the input stream containing the hash blocks + */ + public HashedBlockInputStream(InputStream inputStream) { + this(inputStream, false); + } + + /** + * Create a Hash Block Input Stream with choice of endian encoding + * @param inputStream the input stream containing the hash blocks + * @param littleEndian true if the stream is little endian encoded + */ + public HashedBlockInputStream(InputStream inputStream, boolean littleEndian) { + this.inputStream = inputStream; + this.littleEndian = littleEndian; + } + + @Override + public int read(@NotNull byte[] b, int offset, int length) throws IOException { + return get(b, offset, length); + } + + @Override + public int read() throws IOException { + byte[] buffer = new byte[1]; + if (get(buffer, 0, 1) != 1) { + throw new IOException("Could not read int"); + } + return buffer[0] & 0xFF; + } + + @Override + public void close() throws IOException { + inputStream.close(); + } + + /** + * Gets bytes from the internal buffer and replenishes the buffer as necessary + * @param b a byte array to fill + * @param offset the offset to strat from + * @param length the number of bytes to return + * @return the number of bytes actually returned, , -1 if end of file + * @throws IOException + */ + protected int get(byte[] b, int offset, int length) throws IOException { + if (done) { + return -1; + } + int totalBytesRead = 0; + int bytesRead; + while ((bytesRead = blockInputStream.read(b, offset, length)) < length && !done) { + if (bytesRead == -1) { + load(); + } else { + offset += bytesRead; + length -= bytesRead; + totalBytesRead += bytesRead; + } + } + return bytesRead > 0 ? totalBytesRead + bytesRead : totalBytesRead; + } + + /** + * Reload the internal buffer from the underlying input stream + * @throws IOException + */ + protected void load() throws IOException { + // read the sequence number of the block + long sequenceNumber = readUInt(); + if (sequenceNumber != expectedSequenceNumber) { + throw new IllegalStateException("Expected sequence number " + + expectedSequenceNumber + " got " + sequenceNumber); + } + expectedSequenceNumber++; + + // get the block hash + byte[] hash = new byte[HASH_SIZE]; + readFully(hash); + + // get the length + long readLength = readUInt(); + if (readLength < 0) { + throw new IllegalStateException("Got negative length for block"); + } + // length 0 means end of file + if (readLength == 0) { + if (!Arrays.equals(hash, ZERO_HASH)) { + throw new IllegalStateException("Block hash was not zero on final block"); + } + done = true; + return; + } + + // get the new buffer + byte[] readBuffer = new byte[(int) readLength]; + readFully(readBuffer); + + // check the hash + md5.update(readBuffer); + if (!Arrays.equals(md5.digest(), hash)) { + throw new IllegalStateException("MD5 check failed while reading HashBlock"); + } + blockInputStream = new ByteArrayInputStream(readBuffer); + } + + /** + * Read an unsigned 4 byte int decoding from the endian format + * @return a long holding the value read + * @throws IOException + */ + private long readUInt() throws IOException { + byte[] buf = new byte[4]; + readFully(buf); + if (littleEndian) { + return buf[3] << 24 | (buf[2] & 0xFF) << 16 | (buf[1] & 0xFF) << 8 | (buf[0] & 0xFF); + } + return buf[0] << 24 | (buf[1] & 0xFF) << 16 | (buf[2] & 0xFF) << 8 | (buf[3] & 0xFF); + } + + /** + * Fill the buffer passed + * @param buffer the buffer to fill + * @throws IOException if the buffer could not be filled + */ + private void readFully(byte[] buffer) throws IOException { + int bytesToRead = buffer.length; + int bytesSoFar = 0; + while (bytesSoFar < buffer.length) { + int bytesRead = inputStream.read(buffer, bytesSoFar, bytesToRead); + if (bytesRead <= 0) { + throw new EOFException(); + } + bytesSoFar += bytesRead; + bytesToRead -= bytesRead; + } + } +} diff --git a/platform/credential-store/src/org/linguafranca/hashedblock/HashedBlockOutputStream.java b/platform/credential-store/src/org/linguafranca/hashedblock/HashedBlockOutputStream.java new file mode 100644 index 000000000000..f6cff537267e --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/hashedblock/HashedBlockOutputStream.java @@ -0,0 +1,183 @@ +/* + * Copyright 2015 Jo Rabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.linguafranca.hashedblock; + +import org.jetbrains.annotations.NotNull; + +import java.io.ByteArrayOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Takes a stream of data and formats as Hashed Blocks to the underlying output stream. + * + *
A Hashed block consists of: + * + *
The stream of blocks is terminated with a 0 length 0 hash block. + * + *
Originally developed for KeePass. A KeePass hash block + * stream is little endian, i.e. the sequence + * number and length fields are low order byte first. + * + * @author Jo + */ +public class HashedBlockOutputStream extends OutputStream { + + private static MessageDigest md5; + static { + try { + md5 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + private static final int BLOCK_SIZE = 8 * 1024; + private static final int HASH_SIZE = 32; + private static final byte[] ZERO_HASH = new byte[HASH_SIZE]; + + private int nextSequenceNumber = 0; + private boolean littleEndian = false; + private OutputStream outputStream; + private ByteArrayOutputStream blockOutputStream = new ByteArrayOutputStream(); + private boolean isClosed = false; + + /** + * Create a BigEndian Hash Block Output Stream + * @param outputStream the output stream to receive the hash blocks + */ + public HashedBlockOutputStream(OutputStream outputStream) { + this(outputStream, false); + } + + /** + * Create a Hash Block Output Stream with choice of endian encoding + * @param outputStream the output stream to receive the hash blocks + * @param littleEndian true to encode in a little endian way + */ + public HashedBlockOutputStream(OutputStream outputStream, boolean littleEndian) { + this.outputStream = outputStream; + this.littleEndian = littleEndian; + } + + @Override + public void write(int i) throws IOException { + byte[] buf = new byte[1]; + buf[0] = (byte) i; + put(buf, 0, 1); + } + + @Override + public void write(@NotNull byte[] b, int offset, int count) throws IOException { + put(b, offset, count); + } + + + @Override + public void flush() throws IOException { + save(); + } + + @Override + public void close() throws IOException { + if (isClosed) { + throw new EOFException(); + } + flush(); + writeInt(nextSequenceNumber); + outputStream.write(ZERO_HASH); + writeInt(0); + isClosed = true; + outputStream.flush(); + outputStream.close(); + } + + /** + * Writes to the internal buffer, and writes to the underlying output stream + * as necessary as {@link #BLOCK_SIZE} blocks + * @param b the byte array to write + * @param offset offset in the byte array + * @param length number of bytes to write + * @throws IOException + */ + protected void put(byte[] b, int offset, int length) throws IOException { + if (isClosed) { + throw new EOFException(); + } + while (length > 0) { + int bytesToWrite = Math.min(BLOCK_SIZE - blockOutputStream.size(), length); + blockOutputStream.write(b, offset, bytesToWrite); + if (blockOutputStream.size() >= BLOCK_SIZE) { + save(); + } + offset += bytesToWrite; + length -= bytesToWrite; + } + } + + /** + * Saqve the internal buffer to the underlying stream as a hash block + * @throws IOException + */ + protected void save() throws IOException { + // if there's nothing to save don't do anything + if (blockOutputStream.size() == 0) { + return; + } + // write and increment the block sequence no + writeInt(nextSequenceNumber++); + + // calculate the hash of the buffer + byte[] buffer = blockOutputStream.toByteArray(); + md5.update(buffer); + outputStream.write(md5.digest()); + + // write the buffer's length + writeInt(buffer.length); + + // write the buffer + outputStream.write(buffer); + + // push the contents to disk etc. + outputStream.flush(); + + // reset the internal output buffer for reuse + blockOutputStream.reset(); + } + + /** + * Write a 4 byte int value to the underlying stream in appropriate endian format + * @param value the value to write + * @throws IOException + */ + protected void writeInt(int value) throws IOException { + int output = value; + if (littleEndian) { + output = Integer.reverseBytes(value); + } + outputStream.write(new byte[]{(byte) (output >> 24), (byte) (output >> 16), (byte) (output >> 8), (byte) output}); + } +} diff --git a/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxHeader.java b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxHeader.java new file mode 100644 index 000000000000..a321ece4f91d --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxHeader.java @@ -0,0 +1,206 @@ +/* + * Copyright 2015 Jo Rabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.linguafranca.pwdb.kdbx; + +import org.linguafranca.security.Encryption; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.security.SecureRandom; +import java.util.UUID; + +/** + * This class represents the header portion of a KeePass KDBX file or stream. The header is received in + * plain text and describes the encryption and compression of the remainder of the file. + * + *
It is a factory for encryption and decryption streams and contains a hash of its own serialization. + * + *
While KDBX streams are Little-Endian, data is passed to and from this class in standard Java byte order. + * + * @author jo + */ +public class KdbxHeader { + + /** + * The ordinal 0 represents uncompressed and 1 GZip compressed + */ + public enum CompressionFlags { + NONE, GZIP + } + + /** + * The ordinals represent various types of encryption that may + * be applied to fields within the unencrypted data + * + * @see StreamFormat + * @see KdbxStreamFormat + */ + public enum ProtectedStreamAlgorithm { + NONE, ARC_FOUR, SALSA_20 + } + + /** + * This UUID denotes that AES Cipher is in use. No other values are known. + */ + public static final UUID AES_CIPHER = UUID.fromString("31C1F2E6-BF71-4350-BE58-05216AFC5AFF"); + + /* the cipher in use */ + private UUID cipherUuid; + /* whether the data is compressed */ + private CompressionFlags compressionFlags; + private byte [] masterSeed; + private byte[] transformSeed; + private long transformRounds; + private byte[] encryptionIv; + private byte[] protectedStreamKey; + private ProtectedStreamAlgorithm protectedStreamAlgorithm; + /* these bytes appear in cipher text immediately following the header */ + private byte[] streamStartBytes; + /* not transmitted as part of the header, used in the XML payload, so calculated + * on transmission or receipt */ + private byte[] headerHash; + + /** + * Construct a default KDBX header + */ + public KdbxHeader() { + SecureRandom random = new SecureRandom(); + cipherUuid = AES_CIPHER; + compressionFlags = CompressionFlags.GZIP; + masterSeed = random.generateSeed(32); + transformSeed = random.generateSeed(32); + transformRounds = 6000; + encryptionIv = random.generateSeed(16); + protectedStreamKey = random.generateSeed(32); + streamStartBytes = new byte[32]; + protectedStreamAlgorithm = ProtectedStreamAlgorithm.SALSA_20; + } + + /** + * Create a decrypted input stream using supplied digest and this header + * apply decryption to the passed encrypted input stream + * + * @param digest the key digest + * @param inputStream the encrypted input stream + * @return a decrypted stream + * @throws IOException + */ + public InputStream createDecryptedStream(byte[] digest, InputStream inputStream) throws IOException { + byte[] finalKeyDigest = Encryption.getFinalKeyDigest(digest, getMasterSeed(), getTransformSeed(), getTransformRounds()); + return Encryption.getDecryptedInputStream(inputStream, finalKeyDigest, getEncryptionIv()); + } + + /** + * Create an unencrypted outputstream using the supplied digest and this header + * and use the supplied output stream to write encrypted data. + * @param digest the key digest + * @param outputStream the output stream which is the destination for encrypted data + * @return an output stream to write unencrypted data to + * @throws IOException + */ + public OutputStream createEncryptedStream(byte[] digest, OutputStream outputStream) throws IOException { + byte[] finalKeyDigest = Encryption.getFinalKeyDigest(digest, getMasterSeed(), getTransformSeed(), getTransformRounds()); + return Encryption.getEncryptedOutputStream(outputStream, finalKeyDigest, getEncryptionIv()); + } + + public UUID getCipherUuid() { + return cipherUuid; + } + + public CompressionFlags getCompressionFlags() { + return compressionFlags; + } + + public byte[] getMasterSeed() { + return masterSeed; + } + + public byte[] getTransformSeed() { + return transformSeed; + } + + public long getTransformRounds() { + return transformRounds; + } + + public byte[] getEncryptionIv() { + return encryptionIv; + } + + public byte[] getProtectedStreamKey() { + return protectedStreamKey; + } + + public byte[] getStreamStartBytes() { + return streamStartBytes; + } + + public ProtectedStreamAlgorithm getProtectedStreamAlgorithm() { + return protectedStreamAlgorithm; + } + + public byte[] getHeaderHash() { + return headerHash; + } + + public void setCipherUuid(byte[] uuid) { + ByteBuffer b = ByteBuffer.wrap(uuid); + UUID incoming = new UUID(b.getLong(), b.getLong(8)); + if (!incoming.equals(AES_CIPHER)) { + throw new IllegalStateException("Unknown Cipher UUID " + incoming.toString()); + } + this.cipherUuid = incoming; + } + + public void setCompressionFlags(int flags) { + this.compressionFlags = CompressionFlags.values()[flags]; + } + + public void setMasterSeed(byte[] masterSeed) { + this.masterSeed = masterSeed; + } + + public void setTransformSeed(byte[] transformSeed) { + this.transformSeed = transformSeed; + } + + public void setTransformRounds(long transformRounds) { + this.transformRounds = transformRounds; + } + + public void setEncryptionIv(byte[] encryptionIv) { + this.encryptionIv = encryptionIv; + } + + public void setProtectedStreamKey(byte[] protectedStreamKey) { + this.protectedStreamKey = protectedStreamKey; + } + + public void setStreamStartBytes(byte[] streamStartBytes) { + this.streamStartBytes = streamStartBytes; + } + + public void setInnerRandomStreamId(int innerRandomStreamId) { + this.protectedStreamAlgorithm = ProtectedStreamAlgorithm.values()[innerRandomStreamId]; + } + + public void setHeaderHash(byte[] headerHash) { + this.headerHash = headerHash; + } +} diff --git a/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxSerializer.java b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxSerializer.java new file mode 100644 index 000000000000..2e3a12c762df --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/KdbxSerializer.java @@ -0,0 +1,341 @@ +/* + * Copyright 2015 Jo Rabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.linguafranca.pwdb.kdbx; + +import com.google.common.io.LittleEndianDataInputStream; +import com.google.common.io.LittleEndianDataOutputStream; +import com.intellij.credentialStore.kdbx.KeePassCredentials; +import org.linguafranca.hashedblock.HashedBlockInputStream; +import org.linguafranca.hashedblock.HashedBlockOutputStream; +import org.linguafranca.security.Encryption; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.security.DigestInputStream; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +/** + * This class provides static methods for the encryption and decryption of Keepass KDBX files. + *
+ * A KDBX file is little-endian and consists of the following: + *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.
+ *The content of this payload is expected to be a Keepass Database in XML format.
+ *Salsa20 doesn't quite fit the memory model + * supposed by SerializableDatabase.Encryption - all encrypted + * items have to be en/decrypted in order of encryption, + * i.e. in document order and at the same time. + * + *
The encrypt and decrypt methods + * actually do the same thing. They are here + * only to fulfill the interface contract. + * + * @author jo + */ +public class Salsa20Encryption implements SerializableDatabase.Encryption { + + private final Salsa20Engine salsa20; + private final byte[] key; + + private static final byte[] SALSA20_IV = DatatypeConverter.parseHexBinary("E830094B97205D2A"); + + /** + * Creates a Salsa20 engine + * + * @param key the key to use + * @return an initialized Salsa20 engine + */ + public static Salsa20Engine createSalsa20(byte[] key) { + MessageDigest md = Encryption.getMessageDigestInstance(); + KeyParameter keyParameter = new KeyParameter(md.digest(key)); + ParametersWithIV ivParameter = new ParametersWithIV(keyParameter, SALSA20_IV); + Salsa20Engine engine = new Salsa20Engine(); + engine.init(true, ivParameter); + return engine; + } + + /** + * Constructor creates engine used for both encryption and decryption + * + * @param key the key to use + */ + public Salsa20Encryption(byte[] key) { + this.key = key; + salsa20 = createSalsa20(key); + } + + @Override + public byte[] getKey() { + return key; + } + + @Override + public byte[] decrypt(byte[] encryptedText) { + byte[] output = new byte[encryptedText.length]; + salsa20.processBytes(encryptedText, 0, encryptedText.length, output, 0); + return output; + } + + @Override + public byte[] encrypt(byte[] decryptedText) { + byte[] output = new byte[decryptedText.length]; + salsa20.processBytes(decryptedText, 0, decryptedText.length, output, 0); + return output; + } +} diff --git a/platform/credential-store/src/org/linguafranca/pwdb/kdbx/SerializableDatabase.java b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/SerializableDatabase.java new file mode 100644 index 000000000000..46ee255c82b9 --- /dev/null +++ b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/SerializableDatabase.java @@ -0,0 +1,57 @@ +/* + * Copyright 2015 Jo Rabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.linguafranca.pwdb.kdbx; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * This interface allows for serialization and deserialization of KDBX databases. + * + *
Databases instantiate themselves from a stream and serialize to a stream, + * and need to be able to encrypt and decrypt data (e.g. Protected fields in KDBX format). + * + *
KDBX databases contain a header hash (i.e. a hash of the contents of
+ * some portion of the {@link StreamFormat} they have been loaded from or saved to.
+ * Which means that databases must support the setting of this value after the header
+ * has been written on save, and reading the value after load to allow for integrity checking.
+ *
+ * @author jo
+ */
+public interface SerializableDatabase {
+
+ interface Encryption {
+ byte[] getKey();
+
+ byte[] decrypt(byte[] encryptedText);
+
+ byte[] encrypt(byte[] decryptedText);
+ }
+
+ SerializableDatabase load(InputStream inputStream) throws IOException;
+
+ void save(OutputStream outputStream) throws IOException;
+
+ Encryption getEncryption();
+
+ void setEncryption(Encryption encryption);
+
+ byte[] getHeaderHash();
+
+ void setHeaderHash(byte[] hash);
+}
diff --git a/platform/credential-store/src/org/linguafranca/pwdb/kdbx/dom/DomHelper.java b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/dom/DomHelper.java
new file mode 100644
index 000000000000..7d283fd03ba1
--- /dev/null
+++ b/platform/credential-store/src/org/linguafranca/pwdb/kdbx/dom/DomHelper.java
@@ -0,0 +1,223 @@
+/*
+ * Copyright 2015 Jo Rabin
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.linguafranca.pwdb.kdbx.dom;
+
+import com.intellij.util.text.SyncDateFormat;
+import org.apache.commons.codec.binary.Hex;
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import javax.xml.xpath.XPath;
+import javax.xml.xpath.XPathConstants;
+import javax.xml.xpath.XPathExpressionException;
+import javax.xml.xpath.XPathFactory;
+import java.nio.ByteBuffer;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+//import javax.xml.bind.DatatypeConverter;
+
+/**
+ * The class contains static helper methods for access to the underlying XML DOM
+ *
+ * @author jo
+ */
+public class DomHelper {
+
+ public static final XPath xpath = XPathFactory.newInstance().newXPath();
+
+ public static SyncDateFormat dateFormatter = new SyncDateFormat(new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"));
+
+ public static final String GROUP_ELEMENT_NAME = "Group";
+ public static final String ENTRY_ELEMENT_NAME = "Entry";
+ public static final String ICON_ELEMENT_NAME = "IconID";
+ public static final String UUID_ELEMENT_NAME = "UUID";
+ public static final String NAME_ELEMENT_NAME = "Name";
+ public static final String NOTES_ELEMENT_NAME = "Notes";
+ public static final String TIMES_ELEMENT_NAME = "Times";
+ public static final String IS_EXPANDED = "IsExpanded";
+
+ static final String HISTORY_ELEMENT_NAME = "History";
+
+ public static final String LAST_MODIFICATION_TIME_ELEMENT_NAME = "Times/LastModificationTime";
+ public static final String CREATION_TIME_ELEMENT_NAME = "Times/CreationTime";
+ public static final String LAST_ACCESS_TIME_ELEMENT_NAME = "Times/LastAccessTime";
+ public static final String EXPIRY_TIME_ELEMENT_NAME = "Times/ExpiryTime";
+ public static final String EXPIRES_ELEMENT_NAME = "Times/Expires";
+ public static final String USAGE_COUNT_ELEMENT_NAME = "Times/UsageCount";
+ public static final String LOCATION_CHANGED = "Times/LocationChanged";
+
+ public static final String PROPERTY_ELEMENT_FORMAT = "String[Key/text()='%s']";
+ public static final String VALUE_ELEMENT_NAME = "Value";
+
+ public interface ValueCreator {
+ String getValue();
+ }
+
+ public static class ConstantValueCreator implements ValueCreator {
+ String value;
+ public ConstantValueCreator(String value) {
+ this.value = value;
+ }
+ @Override
+ public String getValue() {
+ return value;
+ }
+ }
+
+ public static class DateValueCreator implements ValueCreator {
+ @Override
+ public String getValue() {
+ return dateFormatter.format(new Date());
+ }
+ }
+
+ public static class UuidValueCreator implements ValueCreator {
+ @Override
+ public String getValue() {
+ return base64RandomUuid();
+ }
+
+ }
+
+ public static void ensureElements (Element element, Map Obviously, perhaps, if the database is added to, or under certain types of modification,
+ * those elements will be missing from a re-serialization.
+ *
+ * @author jo
+ */
+public class DomSerializableDatabase implements SerializableDatabase {
+
+ private Document doc;
+ private Encryption encryption;
+
+ private DomSerializableDatabase() {}
+
+ public static DomSerializableDatabase createEmptyDatabase() throws IOException {
+ DomSerializableDatabase result = new DomSerializableDatabase();
+ // read in the template KeePass XML database
+ result.load(result.getClass().getClassLoader().getResourceAsStream("base.kdbx.xml"));
+ try {
+ // replace all placeholder dates with now
+ String now = dateFormatter.format(new Date());
+ NodeList list = (NodeList) xpath.evaluate("//*[contains(text(),'${creationDate}')]", result.doc.getDocumentElement(), XPathConstants.NODESET);
+ for (int i = 0; i < list.getLength(); i++) {
+ list.item(i).setTextContent(now);
+ }
+ // set the root group UUID
+ Node uuid = (Node) xpath.evaluate("//"+ UUID_ELEMENT_NAME, result.doc.getDocumentElement(), XPathConstants.NODE);
+ uuid.setTextContent(base64RandomUuid());
+ } catch (XPathExpressionException e) {
+ throw new IllegalStateException(e);
+ }
+ result.setEncryption(new Salsa20Encryption(SecureRandom.getSeed(32)));
+ return result;
+ }
+
+ @Override
+ public SerializableDatabase load(InputStream inputStream) throws IOException {
+ DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance();
+ try {
+ DocumentBuilder dBuilder = dbFactory.newDocumentBuilder();
+ doc = dBuilder.parse(inputStream);
+
+ // we need to decrypt all protected fields
+ // TODO we assume they are all strings, which is wrong
+ NodeList protectedContent = (NodeList) xpath.evaluate("//*[@Protected='True']", doc, XPathConstants.NODESET);
+ for (int i = 0; i < protectedContent.getLength(); i++){
+ Element element = ((Element) protectedContent.item(i));
+ String base64 = getElementContent(".", element);
+ byte[] encrypted = DatatypeConverter.parseBase64Binary(base64);
+ String decrypted = new String(encryption.decrypt(encrypted), "UTF-8");
+ setElementContent(".", element, decrypted);
+ element.removeAttribute("Protected");
+ }
+
+ return this;
+ } catch (ParserConfigurationException e) {
+ throw new IllegalStateException("Instantiating Document Builder", e);
+ } catch (SAXException e) {
+ throw new IllegalStateException("Parsing exception", e);
+ } catch (XPathExpressionException e) {
+ throw new IllegalStateException("XPath Exception", e);
+ }
+ }
+
+ @Override
+ public void save(OutputStream outputStream) {
+ Document copyDoc = (Document) doc.cloneNode(true);
+ try {
+ // check whether protection is required and if so mark the element with @Protected='True'
+ prepareProtection(copyDoc, "Title");
+ prepareProtection(copyDoc, "UserName");
+ prepareProtection(copyDoc, "Password");
+ prepareProtection(copyDoc, "Notes");
+ prepareProtection(copyDoc, "URL");
+
+ // encrypt and base64 every element marked as protected
+ NodeList protectedContent = (NodeList) xpath.evaluate("//*[@Protected='True']", copyDoc, XPathConstants.NODESET);
+ for (int i = 0; i < protectedContent.getLength(); i++){
+ Element element = ((Element) protectedContent.item(i));
+ String decrypted = getElementContent(".", element);
+ if (decrypted == null) {
+ decrypted = "";
+ }
+ byte[] encrypted = encryption.encrypt(decrypted.getBytes(StandardCharsets.UTF_8));
+ setElementContent(".", element, Base64.getEncoder().encodeToString(encrypted));
+ }
+
+ } catch (XPathExpressionException e) {
+ throw new IllegalStateException(e);
+ }
+
+ Source xmlSource = new DOMSource(copyDoc);
+ Result outputTarget = new StreamResult(outputStream);
+ try {
+ Transformer transformer = TransformerFactory.newInstance().newTransformer();
+ transformer.setOutputProperty(OutputKeys.INDENT, "yes");
+ transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
+ transformer.transform(xmlSource, outputTarget);
+ } catch (TransformerException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @SuppressWarnings("unused")
+ public static String getStringFromDocument(Document doc) throws TransformerException {
+ StringWriter writer = new StringWriter();
+ Transformer transformer = TransformerFactory.newInstance().newTransformer();
+ transformer.setOutputProperty(OutputKeys.INDENT, "yes");
+ transformer.transform(new DOMSource(doc), new StreamResult(writer));
+ return writer.toString();
+ }
+
+ private static final String protectQuery = "//Meta/MemoryProtection/Protect%s";
+ private static final String pattern = "//String/Key[text()='%s']/following-sibling::Value";
+ private static void prepareProtection(Document doc, String protect) throws XPathExpressionException {
+ // does this require encryption
+ String query = String.format(protectQuery, protect);
+ if (!((String) xpath.evaluate(query, doc, XPathConstants.STRING)).toLowerCase(Locale.ENGLISH).equals("true")) {
+ return;
+ }
+ // mark the field as Protected but don't actually encrypt yet, that comes later
+ String path = String.format(pattern, protect);
+ NodeList nodelist = (NodeList) xpath.evaluate(path, doc, XPathConstants.NODESET);
+ for (int i = 0; i < nodelist.getLength(); i++) {
+ Element element = (Element) nodelist.item(i);
+ element.setAttribute("Protected", "True");
+ }
+ }
+
+ @Override
+ public byte[] getHeaderHash() {
+ try {
+ String base64 = (String) xpath.evaluate("//HeaderHash", doc, XPathConstants.STRING);
+ return DatatypeConverter.parseBase64Binary(base64);
+ } catch (XPathExpressionException e) {
+ throw new IllegalStateException("Can't get header hash", e);
+ }
+ }
+
+ @Override
+ public void setHeaderHash(byte[] hash) {
+ String base64String = DatatypeConverter.printBase64Binary(hash);
+ try {
+ ((Element) xpath.evaluate("//HeaderHash", doc, XPathConstants.NODE)).setTextContent(base64String);
+ } catch (XPathExpressionException e) {
+ throw new IllegalStateException("Can't set header hash", e);
+ }
+ }
+
+
+ @Override
+ public Encryption getEncryption() {
+ return encryption;
+ }
+
+ @Override
+ public void setEncryption(Encryption encryption) {
+ this.encryption = encryption;
+ }
+
+ public Document getDoc() {
+ return doc;
+ }
+}
diff --git a/platform/credential-store/src/org/linguafranca/security/Encryption.java b/platform/credential-store/src/org/linguafranca/security/Encryption.java
new file mode 100644
index 000000000000..b90b6bc8aac7
--- /dev/null
+++ b/platform/credential-store/src/org/linguafranca/security/Encryption.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2015 Jo Rabin
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.linguafranca.security;
+
+import org.bouncycastle.crypto.engines.AESEngine;
+import org.bouncycastle.crypto.engines.AESFastEngine;
+import org.bouncycastle.crypto.io.CipherInputStream;
+import org.bouncycastle.crypto.io.CipherOutputStream;
+import org.bouncycastle.crypto.modes.CBCBlockCipher;
+import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.bouncycastle.crypto.params.ParametersWithIV;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Encryption and decryption utilities..
+ *
+ * @author jo
+ */
+public class Encryption {
+
+ /**
+ * Gets a digest for a UTF-8 encoded string
+ *
+ * @param string the string
+ * @return a digest as a byte array
+ */
+ @SuppressWarnings("unused")
+ public static byte[] getDigest(String string) {
+ return getDigest(string, "UTF-8");
+ }
+
+ /**
+ * Gets a digest for a string
+ *
+ * @param string the string
+ * @param encoding the encoding of the String
+ * @return a digest as a byte array
+ */
+ public static byte[] getDigest(String string, String encoding) {
+ if (string == null || string.length() == 0)
+ throw new IllegalArgumentException("String cannot be null or empty");
+
+ if (encoding == null || encoding.length() == 0)
+ throw new IllegalArgumentException("Encoding cannot be null or empty");
+
+ MessageDigest md = getMessageDigestInstance();
+
+ try {
+ byte[] bytes = string.getBytes(encoding);
+ md.update(bytes, 0, bytes.length);
+ return md.digest();
+ } catch (UnsupportedEncodingException e) {
+ throw new IllegalStateException(encoding + " is not supported");
+ }
+ }
+
+ /**
+ * Gets a SHA-256 message digest instance
+ *
+ * @return A MessageDigest
+ */
+ public static MessageDigest getMessageDigestInstance() {
+ try {
+ return MessageDigest.getInstance("SHA-256");
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 is not supported");
+ }
+ }
+
+ /**
+ * Create a final key from the parameters passed
+ */
+ public static byte[] getFinalKeyDigest(byte[] key, byte[] masterSeed, byte[] transformSeed, long transformRounds) {
+
+ AESEngine engine = new AESEngine();
+ engine.init(true, new KeyParameter(transformSeed));
+
+ // copy input key
+ byte[] transformedKey = new byte[key.length];
+ System.arraycopy(key, 0, transformedKey, 0, transformedKey.length);
+
+ // transform rounds times
+ for (long rounds = 0; rounds < transformRounds; rounds++) {
+ engine.processBlock(transformedKey, 0, transformedKey, 0);
+ engine.processBlock(transformedKey, 16, transformedKey, 16);
+ }
+
+ MessageDigest md = getMessageDigestInstance();
+ byte[] transformedKeyDigest = md.digest(transformedKey);
+
+ md.update(masterSeed);
+ return md.digest(transformedKeyDigest);
+ }
+
+ /**
+ * Create a decrypted input stream from an encrypted one
+ */
+ public static InputStream getDecryptedInputStream (InputStream encryptedInputStream, byte[] keyData, byte[] ivData) {
+ final ParametersWithIV keyAndIV = new ParametersWithIV(new KeyParameter(keyData), ivData);
+ PaddedBufferedBlockCipher pbbc = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESFastEngine()));
+ pbbc.init(false, keyAndIV);
+ return new CipherInputStream(encryptedInputStream, pbbc);
+ }
+
+ /**
+ * Create an encrypted output stream from an unencrypted output stream
+ */
+ public static OutputStream getEncryptedOutputStream (OutputStream decryptedOutputStream, byte[] keyData, byte[] ivData) {
+ final ParametersWithIV keyAndIV = new ParametersWithIV(new KeyParameter(keyData), ivData);
+ PaddedBufferedBlockCipher pbbc = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESFastEngine()));
+ pbbc.init(true, keyAndIV);
+ return new CipherOutputStream(decryptedOutputStream, pbbc);
+ }
+}
diff --git a/platform/credential-store/test/FileCredentialStoreTest.kt b/platform/credential-store/test/FileCredentialStoreTest.kt
index 26bcd981d4fb..95f886f4135f 100644
--- a/platform/credential-store/test/FileCredentialStoreTest.kt
+++ b/platform/credential-store/test/FileCredentialStoreTest.kt
@@ -25,12 +25,14 @@ import java.util.*
private const val TEST_SERVICE_NAME = "IntelliJ Platform Test"
+// part of specific tests in the IcsCredentialTest
class FileCredentialStoreTest {
+ // we don't use in memory fs to check real file io
private val tempDirManager = TemporaryDirectory()
- @Rule
- @JvmField
- val ruleChain = RuleChain(tempDirManager)
+ @Rule
+ @JvmField
+ val ruleChain = RuleChain(tempDirManager)
@Test
fun many() {
@@ -49,13 +51,11 @@ class FileCredentialStoreTest {
provider.deleteFileStorage()
- val pdbFile = baseDir.resolve("pdb")
+ val pdbFile = baseDir.resolve("c.kdbx")
val pdbPwdFile = baseDir.resolve("pdb.pwd")
- val pdbPwdTmpFile = baseDir.resolve("pdb.pwd.tmp")
assertThat(pdbFile).doesNotExist()
assertThat(pdbPwdFile).doesNotExist()
- assertThat(pdbPwdTmpFile).doesNotExist()
}
@Test
@@ -71,15 +71,13 @@ class FileCredentialStoreTest {
assertThat(baseDir).doesNotExist()
- val pdbFile = baseDir.resolve("pdb")
+ val pdbFile = baseDir.resolve("c.kdbx")
val pdbPwdFile = baseDir.resolve("pdb.pwd")
- val pdbPwdTmpFile = baseDir.resolve("pdb.pwd.tmp")
provider.save()
assertThat(pdbFile).isRegularFile()
assertThat(pdbPwdFile).isRegularFile()
- assertThat(pdbPwdTmpFile).doesNotExist()
val amAttributes = CredentialAttributes(TEST_SERVICE_NAME, "am")
provider.setPassword(amAttributes, "pass2")
@@ -94,7 +92,6 @@ class FileCredentialStoreTest {
assertThat(pdbFile).isRegularFile()
assertThat(pdbPwdFile).isRegularFile()
- assertThat(pdbPwdTmpFile).doesNotExist()
provider = FileCredentialStore(baseDirectory = baseDir)
@@ -106,14 +103,9 @@ class FileCredentialStoreTest {
provider.save()
- assertThat(pdbFile).doesNotExist()
- assertThat(pdbPwdFile).doesNotExist()
- assertThat(pdbPwdTmpFile).doesNotExist()
-
provider.deleteFileStorage()
assertThat(pdbFile).doesNotExist()
assertThat(pdbPwdFile).doesNotExist()
- assertThat(pdbPwdTmpFile).doesNotExist()
}
}
diff --git a/platform/platform-api/src/com/intellij/ide/passwordSafe/CredentialAttributes.kt b/platform/platform-api/src/com/intellij/credentialStore/CredentialAttributes.kt
similarity index 74%
rename from platform/platform-api/src/com/intellij/ide/passwordSafe/CredentialAttributes.kt
rename to platform/platform-api/src/com/intellij/credentialStore/CredentialAttributes.kt
index 1f25e7d9e514..dfbdcd8f3bf9 100644
--- a/platform/platform-api/src/com/intellij/ide/passwordSafe/CredentialAttributes.kt
+++ b/platform/platform-api/src/com/intellij/credentialStore/CredentialAttributes.kt
@@ -32,14 +32,4 @@ class Credentials(user: String?, val password: String?) {
override fun hashCode() = (user?.hashCode() ?: 0) * 37 + (password?.hashCode() ?: 0)
}
-interface CredentialStore {
- fun get(attributes: CredentialAttributes): Credentials?
-
- fun getPassword(attributes: CredentialAttributes) = get(attributes)?.password
-
- fun set(attributes: CredentialAttributes, credentials: Credentials?)
-
- fun setPassword(attributes: CredentialAttributes, password: String?) {
- set(attributes, Credentials(attributes.accountName, password))
- }
-}
+fun CredentialAttributes(requestor: Class<*>, accountName: String) = CredentialAttributes(requestor.name, accountName)
\ No newline at end of file
diff --git a/platform/platform-api/src/com/intellij/credentialStore/CredentialStore.java b/platform/platform-api/src/com/intellij/credentialStore/CredentialStore.java
new file mode 100644
index 000000000000..22126f088179
--- /dev/null
+++ b/platform/platform-api/src/com/intellij/credentialStore/CredentialStore.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2000-2016 JetBrains s.r.o.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.intellij.credentialStore;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+public interface CredentialStore {
+ @Nullable
+ Credentials get(@NotNull CredentialAttributes attributes);
+
+ @Nullable
+ default String getPassword(@NotNull CredentialAttributes attributes) {
+ Credentials credentials = get(attributes);
+ return credentials == null ? null : credentials.getPassword();
+ }
+
+ void set(@NotNull CredentialAttributes attributes, @Nullable Credentials credentials);
+
+ default void setPassword(@NotNull CredentialAttributes attributes, @Nullable String password) {
+ set(attributes, password == null ? null : new Credentials(attributes.getAccountName(), password));
+ }
+}
diff --git a/platform/platform-api/src/com/intellij/ide/passwordSafe/PasswordStorage.java b/platform/platform-api/src/com/intellij/ide/passwordSafe/PasswordStorage.java
index 919cf7bd7d3b..f89a348b128a 100644
--- a/platform/platform-api/src/com/intellij/ide/passwordSafe/PasswordStorage.java
+++ b/platform/platform-api/src/com/intellij/ide/passwordSafe/PasswordStorage.java
@@ -15,23 +15,24 @@
*/
package com.intellij.ide.passwordSafe;
-import com.intellij.credentialStore.CredentialAttributes;
import com.intellij.credentialStore.CredentialStore;
import com.intellij.credentialStore.Credentials;
import com.intellij.openapi.project.Project;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import static com.intellij.credentialStore.CredentialAttributesKt.CredentialAttributes;
+
public interface PasswordStorage extends CredentialStore {
@Deprecated
@Nullable
default String getPassword(@NotNull Class> requestor, @NotNull String accountName) {
- return getPassword(createAttributes(requestor, accountName));
+ return getPassword(CredentialAttributes(requestor, accountName));
}
@Deprecated
default void setPassword(@NotNull Class> requestor, @NotNull String accountName, @Nullable String value) {
- set(createAttributes(requestor, accountName), new Credentials(accountName, value));
+ set(CredentialAttributes(requestor, accountName), new Credentials(accountName, value));
}
/**
@@ -59,9 +60,4 @@ public interface PasswordStorage extends CredentialStore {
//noinspection deprecation
return getPassword(requestor, key);
}
-
- @NotNull
- static CredentialAttributes createAttributes(@NotNull Class> requestor, @NotNull String accountName) {
- return new CredentialAttributes(requestor.getName(), accountName);
- }
}
diff --git a/platform/platform-impl/src/com/intellij/ide/passwordSafe/impl/providers/BasePasswordSafeProvider.java b/platform/platform-impl/src/com/intellij/ide/passwordSafe/impl/providers/BasePasswordSafeProvider.java
index 38d9ecd315a6..62288804e0c7 100644
--- a/platform/platform-impl/src/com/intellij/ide/passwordSafe/impl/providers/BasePasswordSafeProvider.java
+++ b/platform/platform-impl/src/com/intellij/ide/passwordSafe/impl/providers/BasePasswordSafeProvider.java
@@ -51,16 +51,4 @@ public abstract class BasePasswordSafeProvider implements PasswordStorage {
}
protected abstract void storeEncryptedPassword(byte[] key, byte[] encryptedPassword);
-
- @Nullable
- @Override
- public final String getPassword(@NotNull CredentialAttributes attributes) {
- Credentials credentials = get(attributes);
- return credentials == null ? null : credentials.getPassword();
- }
-
- @Override
- public final void setPassword(@NotNull CredentialAttributes attributes, @Nullable String password) {
- set(attributes, new Credentials(attributes.getAccountName(), password));
- }
}
diff --git a/platform/platform-impl/src/com/intellij/util/path.kt b/platform/platform-impl/src/com/intellij/util/path.kt
index 162bec02a811..ea60dc3edcb2 100644
--- a/platform/platform-impl/src/com/intellij/util/path.kt
+++ b/platform/platform-impl/src/com/intellij/util/path.kt
@@ -151,6 +151,19 @@ fun Path.writeSafe(data: ByteArray, offset: Int = 0, size: Int = data.size): Pat
return this
}
+fun Path.writeSafe(outConsumer: (OutputStream) -> Unit): Path {
+ val tempFile = parent.resolve("${fileName}.${UUID.randomUUID()}.tmp")
+ tempFile.outputStream().use(outConsumer)
+ try {
+ Files.move(tempFile, this, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
+ }
+ catch (e: IOException) {
+ LOG.warn(e)
+ FileUtil.rename(tempFile.toFile(), this.toFile())
+ }
+ return this
+}
+
fun Path.write(data: String): Path {
parent?.createDirectories()
diff --git a/plugins/settings-repository/settings-repository.iml b/plugins/settings-repository/settings-repository.iml
index a60fcc1535c4..480c5a0ae562 100644
--- a/plugins/settings-repository/settings-repository.iml
+++ b/plugins/settings-repository/settings-repository.iml
@@ -51,5 +51,6 @@