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