Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 32 additions & 9 deletions database/src/main/java/org/linguafranca/pwdb/security/Argon2.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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
Expand All @@ -53,7 +76,7 @@ public static class VariantDictionaryKeys {

@Override
public UUID getKdfUuid() {
return argon2_kdf;
return kdfUuid;
}

@Override
Expand All @@ -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)
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> contents(JacksonDatabase database) {
List<String> 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<String> 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());
}
}
119 changes: 119 additions & 0 deletions kdbx/src/test/java/org/linguafranca/pwdb/kdbx/Argon2idTest.java
Original file line number Diff line number Diff line change
@@ -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("<KeePassFile>"));
}
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());
}
}
}
Binary file not shown.