diff --git a/kdbx/src/main/java/org/linguafranca/pwdb/kdbx/KdbxKeyFile.java b/kdbx/src/main/java/org/linguafranca/pwdb/kdbx/KdbxKeyFile.java index 0c3c1157..9b8ba10c 100644 --- a/kdbx/src/main/java/org/linguafranca/pwdb/kdbx/KdbxKeyFile.java +++ b/kdbx/src/main/java/org/linguafranca/pwdb/kdbx/KdbxKeyFile.java @@ -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 + *

+ * 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'. + *

+ * Formats. KeePass supports the following key file formats: + * + * + * @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 { + } +} \ No newline at end of file