Skip to content

Commit

Permalink
Resolve #41
Browse files Browse the repository at this point in the history
  • Loading branch information
giusvale-dev committed Oct 21, 2023
2 parents 156f527 + bb7c5e2 commit e1cabe9
Showing 1 changed file with 102 additions and 92 deletions.
194 changes: 102 additions & 92 deletions kdbx/src/main/java/org/linguafranca/pwdb/kdbx/KdbxKeyFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,140 +23,150 @@
import org.w3c.dom.Document;
import org.xml.sax.SAXException;



import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamReader;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PushbackInputStream;
import java.security.MessageDigest;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.DigestInputStream;
import java.util.Arrays;
import java.util.Objects;

/**
* Class has a static method to load a key from a KDBX XML Key File
*
* @author jo
* Class has a static method to load a key from an {@link InputStream}
*/
@SuppressWarnings("WeakerAccess")
public class KdbxKeyFile {

private static XPath xpath = XPathFactory.newInstance().newXPath();
private static int BUFFER_SIZE = 65;
private static int KEY_LEN_32 = 32;
private static int KEY_LEN_64 = 64;
private static final XPath xpath = XPathFactory.newInstance().newXPath();
private static final int BUFFER_SIZE = 65;
private static final int KEY_LEN_32 = 32;
private static final int KEY_LEN_64 = 64;

/**
* Load a key from an InputStream with a KDBX XML key file.
*
* @param inputStream the input stream holding the key
* Load a key from an InputStream, in this method the inputStrem represent the KeyFile
* <p>
* A key file is a file that contains a key (and possibly additional data, e.g. a hash that allows to verify the integrity of the key). The file extension typically is 'keyx' or 'key'.
* </p>
* Formats. KeePass supports the following key file formats:
* <ul>
* <li>XML (recommended, default). There is an XML format for key files. KeePass 2.x uses this format by default, i.e. when creating a key file in the master key dialog, an XML key file is created. The syntax and the semantics of the XML format allow to detect certain corruptions (especially such caused by faulty hardware or transfer problems), and a hash (in XML key files version 2.0 or higher) allows to verify the integrity of the key. This format is resistant to most encoding and new-line character changes (which is useful for instance when the user is opening and saving the key file or when transferring it from/to a server). Such a key file can be printed (as a backup on paper), and comments can be added in the file (with the usual XML syntax: <!-- ... -->). It is the most flexible format; new features can be added easily in the future.</li>
* <li>32 bytes. If the key file contains exactly 32 bytes, these are used as a 256-bit cryptographic key. This format requires the least disk space.</li>
* <li>Hexadecimal. If the key file contains exactly 64 hexadecimal characters (0-9 and A-F, in UTF-8/ASCII encoding, one line, no spaces), these are decoded to a 256-bit cryptographic key.</li>
* <li>Hashed. If a key file does not match any of the formats above, its content is hashed using a cryptographic hash function in order to build a key (typically a 256-bit key with SHA-256). This allows to use arbitrary files as key files.</li>
* </ul>
*
* @param inputStream the input stream holding the key, caller should close
* @return the key
*/
public static byte[] load(InputStream inputStream) {
// wrap the stream to get its digest (in case we need it)
DigestInputStream digestInputStream = new DigestInputStream(inputStream,
Encryption.getSha256MessageDigestInstance());
// wrap the stream, so we can test reading from it but then push back to get original stream
PushbackInputStream pis = new PushbackInputStream(digestInputStream, BUFFER_SIZE);
try {

PushbackInputStream pis = new PushbackInputStream(inputStream, BUFFER_SIZE);
byte[] buffer = new byte[65];
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead = pis.read(buffer);

// if length 32 assume binary key file
if (bytesRead == KEY_LEN_32) {
return Arrays.copyOf(buffer, bytesRead);
} else if (bytesRead == KEY_LEN_64) {
byte[] keyFile = Hex.decodeHex(new String(Arrays.copyOf(buffer, bytesRead)));
return keyFile;
} else {
if (isXML(buffer)) {
pis.unread(buffer); // Push back the buffer
return computeXmlKeyFile(pis);
} else {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
outputStream.write(buffer, 0, bytesRead); // Insert the first 65 bytes in the OutputStream
buffer = new byte[1024]; // Increase the buffer
while ((bytesRead = pis.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
// Compute the SHA256 of the InputStream
MessageDigest md = Encryption.getSha256MessageDigestInstance();
byte[] keyFile = md.digest(outputStream.toByteArray());
return keyFile;
}
}
} catch (IOException | DecoderException e) {
throw new IllegalArgumentException(e);
}
}

/**
* Check if the data is an XML
* @param data the data to ckeck
* @return true if the data is an XML
*/
private static boolean isXML(byte[] data) {

try {
XMLInputFactory factory = XMLInputFactory.newFactory();
ByteArrayInputStream bais = new ByteArrayInputStream(data);
XMLStreamReader reader = factory.createXMLStreamReader(bais);
// Attempt to read the start element of the XML document.
if (reader.hasNext()) {
int eventType = reader.next();
if (eventType == XMLStreamReader.START_ELEMENT) {
return true; // The InputStream contains valid XML
// if length 64 may be hex encoded key file
if (bytesRead == KEY_LEN_64) {
try {
ByteBuffer byteBuffer = ByteBuffer.wrap(buffer);
Charset charSet = StandardCharsets.UTF_8;
CharBuffer charBuffer = charSet.decode(byteBuffer);
charBuffer.limit(KEY_LEN_64);
char[] hexValue = new char[charBuffer.remaining()];
charBuffer.get(hexValue);
return Hex.decodeHex(hexValue); // (avoid creating a String)
} catch (DecoderException ignored) {
// fall through it may be an XML file or just a file whose digest we want
}
}
// If we reach this point, it's not valid XML.
return false;
} catch (Exception e) {
// An exception occurred, so it's not valid XML.
return false;
// restore stream
pis.unread(buffer);

// if length not 32 or 64 either an XML key file or just a file to get digest
try {
// see if it's an XML key file
return tryComputeXmlKeyFile(pis);
} catch (HashMismatchException e) {
throw new IllegalArgumentException("Invalid key in signature file");
} catch (Exception ignored) {
// fall through to get file digest
}

// is not a valid xml file, so read the remainder of file
byte[] sink = new byte[1024];
// read file to get its digest
//noinspection StatementWithEmptyBody
while (digestInputStream.read(sink) > 0) { /* nothing */ }
return digestInputStream.getMessageDigest().digest();
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
}

/**
* Read the InputStream (keyx file) and compute the hash (SHA2-256) to build a key
*
* @param is The KeyFile as an InputStream
* Read the InputStream (kdbx xml keyfile) and compute the hash (SHA-256) to build a key
*
* @param is The KeyFile as an InputStream, must return with stream open on error
* @return the computed byte array (keyFile) to compute the MasterKey
*/
private static byte[] computeXmlKeyFile(InputStream is) {

private static byte[] tryComputeXmlKeyFile(InputStream is) throws HashMismatchException {
// DocumentBuilder closes input stream so wrap inputStream to inhibit this in case of failure
InputStream unCloseable = new FilterInputStream(is) {
@Override
public void close() { /* nothing */ }
};
try {
DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
Document doc = documentBuilder.parse(new PushbackInputStream(is)); // This function close the input stream, we
// need to create another one
String version = (String) xpath.evaluate("//KeyFile/Meta/Version/text()", doc, XPathConstants.STRING);
Document doc = documentBuilder.parse(unCloseable);
// get the key
String data = (String) xpath.evaluate("//KeyFile/Key/Data/text()", doc, XPathConstants.STRING);
if (data == null) {
return null;
throw new IllegalArgumentException("Key file does not contain a key");
}
if (version.equals("2.0")) {
byte[] hexData = Hex.decodeHex(data.replaceAll("\\s", ""));
MessageDigest md = Encryption.getSha256MessageDigestInstance();
byte[] computedHash = md.digest(hexData);
String hashToCheck = (String) xpath.evaluate("//KeyFile/Key/Data/@Hash", doc, XPathConstants.STRING);
byte[] verifiedHash = Hex.decodeHex(hashToCheck);

boolean isHashVerified = Arrays.equals(Arrays.copyOf(computedHash, verifiedHash.length),
verifiedHash);
if (!isHashVerified) {
throw new IllegalStateException("Hash mismatch error");
}
return hexData;
// get the file version
String version = (String) xpath.evaluate("//KeyFile/Meta/Version/text()", doc, XPathConstants.STRING);
// if not 2.0 then key is base64 encoded
if (Objects.isNull(version) || !version.equals("2.0")) {
return Base64.decodeBase64(data);
}

// key data may contain white space
byte[] decodedData = Hex.decodeHex(data.replaceAll("\\s", ""));
byte[] decodedDataHash = Encryption.getSha256MessageDigestInstance().digest(decodedData);

// hash used to verify the data
String hashToCheck = (String) xpath.evaluate("//KeyFile/Key/Data/@Hash", doc, XPathConstants.STRING);
byte[] decodedHashToCheck = Hex.decodeHex(hashToCheck);

// hashToCheck is a truncated version of the actual hash
if (!Arrays.equals(Arrays.copyOf(decodedDataHash, decodedHashToCheck.length), decodedHashToCheck)) {
throw new HashMismatchException();
}
return Base64.decodeBase64(data.getBytes());
} catch(IOException | SAXException | ParserConfigurationException | XPathExpressionException | DecoderException e) {
throw new IllegalArgumentException("An error occours during XML parsing: " + e.getMessage());
return decodedData;
} catch (IOException | SAXException | ParserConfigurationException | XPathExpressionException |
DecoderException e) {
throw new IllegalArgumentException("An error occurred during XML parsing: " + e.getMessage());
}
}
}
private static class HashMismatchException extends Exception {
}
}

0 comments on commit e1cabe9

Please sign in to comment.