diff --git a/CHANGELOG.md b/CHANGELOG.md index cae92d32..39743803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Trying to follow the suggestions at [Keep a Change Log](http://keepachangelog.co # [2.2.7] Unreleased - [Issue 108] Tests that write to `testOutput` create it first (`TestUtil.testOutputPath` in the `util` module), so they pass in any order and when run on their own; this completes the fix in [PR 85] for issue 83, which did it only for `QuickStart` +- KDBX 4 files using Argon2id for key derivation can be loaded and saved: new `Encryption.KeyDerivationFunction.ARGON2ID` (`Argon2.getArgon2idInstance()`), alongside `ARGON2`, which is Argon2d. Until now loading such a file, e.g. one created by KeePass or KeePassXC with Argon2id selected, failed with `IllegalArgumentException: Unknown Cipher UUID` # [2.2.6] 2026-10-03 diff --git a/database/src/main/java/org/linguafranca/pwdb/security/Argon2.java b/database/src/main/java/org/linguafranca/pwdb/security/Argon2.java index a0ba2984..e15d7bea 100644 --- a/database/src/main/java/org/linguafranca/pwdb/security/Argon2.java +++ b/database/src/main/java/org/linguafranca/pwdb/security/Argon2.java @@ -10,31 +10,54 @@ /** - * KDBX V4 files may use Argon2 for key derivation. + * KDBX V4 files may use Argon2 for key derivation, either in its Argon2d or its Argon2id variant. + * KeePass identifies the two variants by different KDF UUIDs, the parameters are the same. *

- * A singleton + * Two singletons, see {@link #getInstance()} and {@link #getArgon2idInstance()} */ public class Argon2 implements KeyDerivationFunction { /** - * UUID indicating that Argon is being used as the KDF + * UUID indicating that Argon2d is being used as the KDF */ private static final UUID argon2_kdf = UUID.fromString("EF636DDF-8C29-444B-91F7-A9A403E30A0C"); - private static final String name = "Argon2"; + /** + * UUID indicating that Argon2id is being used as the KDF + */ + private static final UUID argon2id_kdf = UUID.fromString("9E298B19-56DB-4773-B23D-FC3EC6F0A1E6"); + + private final UUID kdfUuid; + private final String name; + private final int argon2Type; /** * hide constructor */ - private Argon2() { + private Argon2(UUID kdfUuid, String name, int argon2Type) { + this.kdfUuid = kdfUuid; + this.name = name; + this.argon2Type = argon2Type; } - private static final Argon2 instance = new Argon2(); + private static final Argon2 instance = new Argon2(argon2_kdf, "Argon2", Argon2Parameters.ARGON2_d); + private static final Argon2 argon2idInstance = new Argon2(argon2id_kdf, "Argon2id", Argon2Parameters.ARGON2_id); + + /** + * @return the Argon2d variant + */ public static Argon2 getInstance() { return instance; } + /** + * @return the Argon2id variant + */ + public static Argon2 getArgon2idInstance() { + return argon2idInstance; + } + /** * keys into the variant dictionary supplied as a KDBX header @@ -53,7 +76,7 @@ public static class VariantDictionaryKeys { @Override public UUID getKdfUuid() { - return argon2_kdf; + return kdfUuid; } @Override @@ -69,7 +92,7 @@ public byte[] getTransformedKey(byte[] digest, VariantDictionary argonParameterK int memoryCost = (int) argonParameterKeys.mustGet(paramMemory).asLong(); int timeCost = (int) argonParameterKeys.mustGet(paramIterations).asLong(); - Argon2Parameters.Builder builder = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_d) + Argon2Parameters.Builder builder = new Argon2Parameters.Builder(argon2Type) .withVersion(version) .withIterations(timeCost) .withMemoryAsKB(memoryCost/1024) @@ -92,7 +115,7 @@ public VariantDictionary createKdfParameters() { vd.putUInt("V", 19); vd.putULong("I", 2); vd.putULong("M", 64 * 1024 * 1024); - vd.putUuid("$UUID", Argon2.argon2_kdf); + vd.putUuid("$UUID", kdfUuid); vd.put("S", VariantDictionary.EntryType.ARRAY, random.generateSeed(32)); return vd; } diff --git a/database/src/main/java/org/linguafranca/pwdb/security/Encryption.java b/database/src/main/java/org/linguafranca/pwdb/security/Encryption.java index 36d4a46e..76eeda42 100644 --- a/database/src/main/java/org/linguafranca/pwdb/security/Encryption.java +++ b/database/src/main/java/org/linguafranca/pwdb/security/Encryption.java @@ -97,7 +97,8 @@ public static byte[] transformHmacKey(byte[] digest, byte[] transform) { */ public enum KeyDerivationFunction implements org.linguafranca.pwdb.security.KeyDerivationFunction { AES(Aes.getInstance()), - ARGON2(Argon2.getInstance()); + ARGON2(Argon2.getInstance()), + ARGON2ID(Argon2.getArgon2idInstance()); private final org.linguafranca.pwdb.security.KeyDerivationFunction kdf; diff --git a/jackson/src/test/java/org/linguafranca/pwdb/JacksonArgon2idTest.java b/jackson/src/test/java/org/linguafranca/pwdb/JacksonArgon2idTest.java new file mode 100644 index 00000000..6b75b1ca --- /dev/null +++ b/jackson/src/test/java/org/linguafranca/pwdb/JacksonArgon2idTest.java @@ -0,0 +1,94 @@ +package org.linguafranca.pwdb; + +import org.junit.Test; +import org.linguafranca.pwdb.kdbx.KdbxCreds; +import org.linguafranca.pwdb.kdbx.KdbxHeader; +import org.linguafranca.pwdb.kdbx.KdbxStreamFormat; +import org.linguafranca.pwdb.kdbx.jackson.JacksonDatabase; +import org.linguafranca.pwdb.kdbx.jackson.JacksonEntry; +import org.linguafranca.pwdb.security.Encryption; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +/** + * Open and save KDBX 4 databases whose key derivation function is Argon2id + */ +public class JacksonArgon2idTest { + + /** + * Created by KeePassXC: KDBX 4.1, AES-256, Argon2id (64 MiB, 2 iterations, parallelism 4), password "probe1234" + */ + static final String ARGON2ID_RESOURCE = "V4-AES-Argon2id-probe1234.kdbx"; + static final Credentials ARGON2ID_CREDS = new KdbxCreds("probe1234".getBytes()); + + private JacksonDatabase loadResource() throws IOException { + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(ARGON2ID_RESOURCE)) { + return JacksonDatabase.load(ARGON2ID_CREDS, inputStream); + } + } + + private static KdbxHeader header(JacksonDatabase database) { + return (KdbxHeader) database.getStreamFormat().getStreamConfiguration(); + } + + private static List contents(JacksonDatabase database) { + List result = new ArrayList<>(); + for (JacksonEntry entry : database.findEntries("")) { + result.add(entry.getUuid() + "|" + entry.getTitle() + "|" + entry.getUsername() + "|" + + entry.getPassword() + "|" + entry.getUrl() + "|" + entry.getNotes()); + } + return result; + } + + @Test + public void openKeePassXcArgon2id() throws IOException { + JacksonDatabase database = loadResource(); + assertEquals(Encryption.KeyDerivationFunction.ARGON2ID, header(database).getKeyDerivationFunction()); + assertFalse(contents(database).isEmpty()); + } + + /** + * Saving a database loaded from an Argon2id file keeps Argon2id and the content + */ + @Test + public void saveKeepsArgon2id() throws IOException { + JacksonDatabase database = loadResource(); + List before = contents(database); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + database.save(ARGON2ID_CREDS, outputStream); + + JacksonDatabase reloaded = JacksonDatabase.load(ARGON2ID_CREDS, new ByteArrayInputStream(outputStream.toByteArray())); + assertEquals(Encryption.KeyDerivationFunction.ARGON2ID, header(reloaded).getKeyDerivationFunction()); + assertEquals(before, contents(reloaded)); + } + + /** + * A new database saved with Argon2id chosen explicitly + */ + @Test + public void saveNewDatabaseWithArgon2id() throws IOException { + JacksonDatabase database = new JacksonDatabase(); + JacksonEntry entry = database.newEntry(); + entry.setTitle("Argon2id entry"); + entry.setPassword("secret"); + database.getRootGroup().addEntry(entry); + + KdbxHeader kdbxHeader = new KdbxHeader(4); + kdbxHeader.setKeyDerivationFunction(Encryption.KeyDerivationFunction.ARGON2ID); + Credentials credentials = new KdbxCreds("123".getBytes()); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + database.save(new KdbxStreamFormat(kdbxHeader), credentials, outputStream); + + JacksonDatabase reloaded = JacksonDatabase.load(credentials, new ByteArrayInputStream(outputStream.toByteArray())); + assertEquals(Encryption.KeyDerivationFunction.ARGON2ID, header(reloaded).getKeyDerivationFunction()); + assertEquals("secret", reloaded.findEntries("Argon2id entry").get(0).getPassword()); + } +} diff --git a/kdbx/src/test/java/org/linguafranca/pwdb/kdbx/Argon2idTest.java b/kdbx/src/test/java/org/linguafranca/pwdb/kdbx/Argon2idTest.java new file mode 100644 index 00000000..4798d11b --- /dev/null +++ b/kdbx/src/test/java/org/linguafranca/pwdb/kdbx/Argon2idTest.java @@ -0,0 +1,119 @@ +package org.linguafranca.pwdb.kdbx; + +import com.google.common.io.CharStreams; +import org.junit.Test; +import org.linguafranca.pwdb.Credentials; +import org.linguafranca.pwdb.security.Argon2; +import org.linguafranca.pwdb.security.Encryption; +import org.linguafranca.pwdb.security.VariantDictionary; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Scanner; +import java.util.UUID; + +import static org.junit.Assert.*; + +/** + * KDBX 4 files whose key derivation function is Argon2id rather than Argon2d + */ +public class Argon2idTest { + + /** + * Created by KeePassXC: KDBX 4.1, AES-256, Argon2id (64 MiB, 2 iterations, parallelism 4), password "probe1234" + */ + static final String ARGON2ID_RESOURCE = "V4-AES-Argon2id-probe1234.kdbx"; + static final Credentials ARGON2ID_CREDS = new KdbxCreds("probe1234".getBytes()); + + static final UUID ARGON2D_UUID = UUID.fromString("EF636DDF-8C29-444B-91F7-A9A403E30A0C"); + static final UUID ARGON2ID_UUID = UUID.fromString("9E298B19-56DB-4773-B23D-FC3EC6F0A1E6"); + + @Test + public void kdfUuids() { + assertEquals(ARGON2D_UUID, Encryption.KeyDerivationFunction.ARGON2.getKdfUuid()); + assertEquals(ARGON2ID_UUID, Encryption.KeyDerivationFunction.ARGON2ID.getKdfUuid()); + assertEquals(Encryption.KeyDerivationFunction.ARGON2ID, Encryption.KeyDerivationFunction.getKdf(ARGON2ID_UUID)); + assertEquals("Argon2id", Encryption.KeyDerivationFunction.ARGON2ID.getName()); + } + + @Test + public void createKdfParametersUsesOwnUuid() { + assertEquals(ARGON2D_UUID, Encryption.KeyDerivationFunction.ARGON2.createKdfParameters().mustGet("$UUID").asUuid()); + assertEquals(ARGON2ID_UUID, Encryption.KeyDerivationFunction.ARGON2ID.createKdfParameters().mustGet("$UUID").asUuid()); + } + + /** + * The same parameters must give different keys for the two variants, i.e. Argon2id is not Argon2d under another name + */ + @Test + public void variantsGiveDifferentKeys() { + VariantDictionary parameters = Argon2.getInstance().createKdfParameters(); + parameters.putULong("M", 1024 * 1024); + parameters.putULong("I", 1); + byte[] digest = new byte[32]; + byte[] argon2d = Argon2.getInstance().getTransformedKey(digest, parameters); + byte[] argon2id = Argon2.getArgon2idInstance().getTransformedKey(digest, parameters); + assertEquals(32, argon2id.length); + assertFalse(Arrays.equals(argon2d, argon2id)); + } + + @Test + public void loadArgon2idHeader() throws IOException { + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(ARGON2ID_RESOURCE)) { + KdbxHeader header = KdbxSerializer.readOuterHeader(inputStream, new KdbxHeader()); + assertEquals(4, header.getVersion()); + assertEquals(Encryption.KeyDerivationFunction.ARGON2ID, header.getKeyDerivationFunction()); + } + } + + /** + * Decrypt a file written by KeePassXC with Argon2id + */ + @Test + public void decryptKeePassXcArgon2id() throws IOException { + KdbxHeader header = new KdbxHeader(); + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(ARGON2ID_RESOURCE); + InputStream decrypted = KdbxSerializer.createUnencryptedInputStream(ARGON2ID_CREDS, header, inputStream)) { + String xml = CharStreams.toString(new InputStreamReader(decrypted, StandardCharsets.UTF_8)); + assertTrue(xml.contains("")); + } + assertEquals(Encryption.KeyDerivationFunction.ARGON2ID, header.getKeyDerivationFunction()); + } + + @Test(expected = IllegalStateException.class) + public void wrongPasswordFails() throws IOException { + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(ARGON2ID_RESOURCE)) { + KdbxSerializer.createUnencryptedInputStream(new KdbxCreds("wrong".getBytes()), new KdbxHeader(), inputStream); + } + } + + /** + * Write a KDBX 4 stream using Argon2id and read it back + */ + @Test + public void writeAndReadArgon2id() throws IOException { + Credentials credentials = new KdbxCreds("123".getBytes()); + KdbxHeader kdbxHeader = new KdbxHeader(4); + kdbxHeader.setKeyDerivationFunction(Encryption.KeyDerivationFunction.ARGON2ID); + assertEquals(ARGON2ID_UUID, kdbxHeader.getKdfParameters().mustGet("$UUID").asUuid()); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (OutputStream outputStream = KdbxSerializer.createEncryptedOutputStream(credentials, kdbxHeader, bytes)) { + outputStream.write("Hello World\n".getBytes()); + } + + KdbxHeader readHeader = new KdbxHeader(); + try (InputStream inputStream = KdbxSerializer.createUnencryptedInputStream(credentials, readHeader, new ByteArrayInputStream(bytes.toByteArray()))) { + assertEquals(Encryption.KeyDerivationFunction.ARGON2ID, readHeader.getKeyDerivationFunction()); + assertEquals(ARGON2ID_UUID, readHeader.getKdfParameters().mustGet("$UUID").asUuid()); + Scanner scanner = new Scanner(inputStream); + assertEquals("Hello World", scanner.nextLine()); + } + } +} diff --git a/test/src/test/resources/V4-AES-Argon2id-probe1234.kdbx b/test/src/test/resources/V4-AES-Argon2id-probe1234.kdbx new file mode 100644 index 00000000..cae34feb Binary files /dev/null and b/test/src/test/resources/V4-AES-Argon2id-probe1234.kdbx differ