diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..dc3698d
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,29 @@
+name: Test
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ java: ['11', '17', '21', '25']
+ name: Java ${{ matrix.java }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK ${{ matrix.java }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ matrix.java }}
+ distribution: temurin
+ cache: maven
+
+ - name: Verify (compile + test)
+ run: mvn --batch-mode --no-transfer-progress verify
diff --git a/README.md b/README.md
index 58cb3a2..8f92e87 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,8 @@ string. v1 uses AES-256-GCM with a 12-byte random IV:
Encrypted values look like
`3ncr.org/1#pHRufQld0SajqjHx+FmLMcORfNQi1d674ziOPpG52hqW5+0zfJD91hjXsBsvULVtB017mEghGy3Ohj+GgQY5MQ`.
-Requires JDK 11+. AES-256-GCM and SHA3-256 come from the JDK; Argon2id and
-PBKDF2-SHA3 come from [Bouncy Castle](https://www.bouncycastle.org/).
+Requires JDK 11+. AES-256-GCM and SHA3-256 come from the JDK; Argon2id comes
+from [Bouncy Castle](https://www.bouncycastle.org/).
## Install
@@ -69,17 +69,6 @@ token), hash it through SHA3-256:
TokenCrypt tc = TokenCrypt.fromSha3("some-high-entropy-api-token");
```
-### Legacy: PBKDF2-SHA3
-
-The original `(secret, salt, iterations)` KDF is kept for backward compatibility
-with data encrypted by earlier 3ncr.org libraries. It is **deprecated** — prefer
-`fromArgon2id`, `fromRawKey`, or `fromSha3` for new code.
-
-```java
-@SuppressWarnings("deprecation")
-TokenCrypt tc = TokenCrypt.fromPbkdf2Sha3("my-secret", "my-salt", 1000);
-```
-
### Encrypt / decrypt
```java
@@ -100,14 +89,16 @@ Decryption failures (bad tag, truncated input, malformed base64) throw
## Cross-implementation interop
-This implementation decrypts the canonical v1 test vectors shared with the
-[Go](https://github.com/3ncr/tokencrypt),
+This implementation decrypts the canonical v1 envelope test vectors shared with
+the [Go](https://github.com/3ncr/tokencrypt),
[Node.js](https://github.com/3ncr/nodencrypt),
[PHP](https://github.com/3ncr/tokencrypt-php),
[Python](https://github.com/3ncr/tokencrypt-python), and
-[Rust](https://github.com/3ncr/tokencrypt-rust) reference libraries
-(`secret = "a"`, `salt = "b"`, `iterations = 1000`). See
-`src/test/java/org/_3ncr/tokencrypt/TokenCryptTest.java`.
+[Rust](https://github.com/3ncr/tokencrypt-rust) reference libraries. The 32-byte
+AES key those vectors were originally derived from (PBKDF2-SHA3-256 of
+`secret = "a"`, `salt = "b"`, `iterations = 1000`) is hardcoded in the test
+suite for envelope-level interop — this library only exposes the modern KDFs.
+See `src/test/java/org/_3ncr/tokencrypt/TokenCryptTest.java`.
## License
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..2997892
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,69 @@
+
+
The v1 envelope is {@code 3ncr.org/1# Use this when your secret is already high-entropy and exactly 32 bytes
+ * (for example, loaded from a key-management service).
+ */
+ public static TokenCrypt fromRawKey(byte[] key) {
+ return new TokenCrypt(key);
+ }
+
+ /**
+ * Derive the AES key from a high-entropy secret via a single SHA3-256 hash.
+ *
+ * Suitable for random pre-shared keys, UUIDs, or long random API tokens —
+ * inputs that already carry at least 128 bits of unique entropy. For
+ * low-entropy inputs such as user passwords, prefer
+ * {@link #fromArgon2id(byte[], byte[])}.
+ */
+ public static TokenCrypt fromSha3(byte[] secret) {
+ MessageDigest md;
+ try {
+ md = MessageDigest.getInstance("SHA3-256");
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA3-256 not available", e);
+ }
+ return new TokenCrypt(md.digest(secret));
+ }
+
+ /** Convenience overload: UTF-8 encodes {@code secret} before hashing. */
+ public static TokenCrypt fromSha3(String secret) {
+ return fromSha3(secret.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Derive the AES key from a low-entropy secret via Argon2id using the
+ * 3ncr.org v1 recommended parameters (m=19456 KiB, t=2, p=1).
+ *
+ * {@code salt} must be at least 16 bytes. For deterministic derivation
+ * across implementations, pass the same salt.
+ */
+ public static TokenCrypt fromArgon2id(byte[] secret, byte[] salt) {
+ if (salt == null || salt.length < ARGON2ID_MIN_SALT_BYTES) {
+ int got = (salt == null) ? 0 : salt.length;
+ throw new IllegalArgumentException(
+ "salt must be at least " + ARGON2ID_MIN_SALT_BYTES + " bytes, got " + got);
+ }
+ Argon2Parameters params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
+ .withVersion(Argon2Parameters.ARGON2_VERSION_13)
+ .withMemoryAsKB(ARGON2ID_MEMORY_KIB)
+ .withIterations(ARGON2ID_TIME_COST)
+ .withParallelism(ARGON2ID_PARALLELISM)
+ .withSalt(salt)
+ .build();
+ Argon2BytesGenerator gen = new Argon2BytesGenerator();
+ gen.init(params);
+ byte[] key = new byte[AES_KEY_SIZE];
+ gen.generateBytes(secret, key);
+ return new TokenCrypt(key);
+ }
+
+ /** Convenience overload: UTF-8 encodes {@code secret} before hashing. */
+ public static TokenCrypt fromArgon2id(String secret, byte[] salt) {
+ return fromArgon2id(secret.getBytes(StandardCharsets.UTF_8), salt);
+ }
+
+ /** Encrypt a UTF-8 string and return a {@code 3ncr.org/1#...} value. */
+ public String encrypt3ncr(String plaintext) {
+ if (plaintext == null) {
+ throw new IllegalArgumentException("plaintext must not be null");
+ }
+ byte[] iv = new byte[IV_SIZE];
+ RNG.nextBytes(iv);
+ byte[] ctAndTag;
+ try {
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(TAG_BITS, iv));
+ ctAndTag = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("AES-GCM encryption failed", e);
+ }
+ byte[] buf = new byte[IV_SIZE + ctAndTag.length];
+ System.arraycopy(iv, 0, buf, 0, IV_SIZE);
+ System.arraycopy(ctAndTag, 0, buf, IV_SIZE, ctAndTag.length);
+ return HEADER_V1 + Base64.getEncoder().withoutPadding().encodeToString(buf);
+ }
+
+ /**
+ * If {@code value} has the {@code 3ncr.org/1#} header, decrypt it;
+ * otherwise return it unchanged.
+ *
+ * This makes it safe to route every configuration value through
+ * {@code decryptIf3ncr} regardless of whether it was encrypted.
+ *
+ * @throws TokenCryptException if the value is a 3ncr token but cannot be
+ * decoded or authenticated.
+ */
+ public String decryptIf3ncr(String value) {
+ if (value == null) {
+ throw new IllegalArgumentException("value must not be null");
+ }
+ if (!value.startsWith(HEADER_V1)) {
+ return value;
+ }
+ return decrypt(value.substring(HEADER_V1.length()));
+ }
+
+ private String decrypt(String body) {
+ byte[] buf;
+ try {
+ // Spec emits no padding; JDK Basic decoder accepts both for robustness.
+ buf = Base64.getDecoder().decode(body);
+ } catch (IllegalArgumentException e) {
+ throw new TokenCryptException("invalid base64 payload", e);
+ }
+ if (buf.length < IV_SIZE + TAG_SIZE) {
+ throw new TokenCryptException("truncated 3ncr token");
+ }
+ byte[] iv = Arrays.copyOfRange(buf, 0, IV_SIZE);
+ byte[] ctAndTag = Arrays.copyOfRange(buf, IV_SIZE, buf.length);
+ byte[] plaintext;
+ try {
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(TAG_BITS, iv));
+ plaintext = cipher.doFinal(ctAndTag);
+ } catch (GeneralSecurityException e) {
+ throw new TokenCryptException("authentication tag verification failed", e);
+ }
+ return new String(plaintext, StandardCharsets.UTF_8);
+ }
+}
diff --git a/src/main/java/org/_3ncr/tokencrypt/TokenCryptException.java b/src/main/java/org/_3ncr/tokencrypt/TokenCryptException.java
new file mode 100644
index 0000000..5c907ac
--- /dev/null
+++ b/src/main/java/org/_3ncr/tokencrypt/TokenCryptException.java
@@ -0,0 +1,18 @@
+package org._3ncr.tokencrypt;
+
+/**
+ * Thrown when a {@code 3ncr.org/1#...} value cannot be decoded or decrypted
+ * (malformed base64, truncated payload, or authentication tag mismatch).
+ */
+public class TokenCryptException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ public TokenCryptException(String message) {
+ super(message);
+ }
+
+ public TokenCryptException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/test/java/org/_3ncr/tokencrypt/TokenCryptTest.java b/src/test/java/org/_3ncr/tokencrypt/TokenCryptTest.java
new file mode 100644
index 0000000..14a9095
--- /dev/null
+++ b/src/test/java/org/_3ncr/tokencrypt/TokenCryptTest.java
@@ -0,0 +1,222 @@
+package org._3ncr.tokencrypt;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class TokenCryptTest {
+
+ private static final SecureRandom RNG = new SecureRandom();
+
+ /**
+ * Canonical v1 envelope test vectors — shared with Go, Node, PHP, Python,
+ * Rust, and other implementations. The 32-byte AES key was originally
+ * derived via the legacy PBKDF2-SHA3-256 KDF with secret="a", salt="b",
+ * iterations=1000; this library only supports the modern KDFs, so the
+ * derived key is hardcoded here so we can still verify envelope-level
+ * interop.
+ */
+ private static final byte[] CANONICAL_KEY = new byte[] {
+ (byte) 0x2f, (byte) 0x84, (byte) 0x15, (byte) 0x18, (byte) 0x69, (byte) 0xd7, (byte) 0xd2, (byte) 0x25,
+ (byte) 0x5d, (byte) 0x62, (byte) 0xb3, (byte) 0x32, (byte) 0x0e, (byte) 0x97, (byte) 0x42, (byte) 0x9b,
+ (byte) 0xde, (byte) 0x5a, (byte) 0xac, (byte) 0x04, (byte) 0xa0, (byte) 0x57, (byte) 0x3b, (byte) 0x24,
+ (byte) 0x68, (byte) 0x52, (byte) 0x9a, (byte) 0x74, (byte) 0x17, (byte) 0x51, (byte) 0x5f, (byte) 0x87,
+ };
+
+ private static Stream