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 @@ + + + 4.0.0 + + org._3ncr + tokencrypt + 1.0.0 + jar + + tokencrypt + Java implementation of the 3ncr.org v1 string encryption standard (AES-256-GCM). + https://3ncr.org/ + + + + MIT License + https://opensource.org/licenses/MIT + repo + + + + + https://github.com/3ncr/tokencrypt-java + scm:git:https://github.com/3ncr/tokencrypt-java.git + scm:git:git@github.com:3ncr/tokencrypt-java.git + + + + UTF-8 + UTF-8 + 11 + 11 + 11 + 1.78.1 + 5.10.2 + + + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + diff --git a/src/main/java/org/_3ncr/tokencrypt/TokenCrypt.java b/src/main/java/org/_3ncr/tokencrypt/TokenCrypt.java new file mode 100644 index 0000000..3982b9e --- /dev/null +++ b/src/main/java/org/_3ncr/tokencrypt/TokenCrypt.java @@ -0,0 +1,188 @@ +package org._3ncr.tokencrypt; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.bouncycastle.crypto.generators.Argon2BytesGenerator; +import org.bouncycastle.crypto.params.Argon2Parameters; + +/** + * Java implementation of the 3ncr.org v1 string + * encryption standard. + * + *

The v1 envelope is {@code 3ncr.org/1#} + * using AES-256-GCM with a 12-byte random IV and base64 without padding. The + * envelope is agnostic of how the 32-byte AES key was derived; pick a factory + * based on the entropy of the input secret. + */ +public final class TokenCrypt { + + /** 3ncr.org v1 envelope header. */ + public static final String HEADER_V1 = "3ncr.org/1#"; + + private static final int AES_KEY_SIZE = 32; + private static final int IV_SIZE = 12; + private static final int TAG_SIZE = 16; + private static final int TAG_BITS = TAG_SIZE * 8; + + // 3ncr.org recommended Argon2id parameters (https://3ncr.org/1/ — Key + // Derivation section). + private static final int ARGON2ID_MEMORY_KIB = 19456; + private static final int ARGON2ID_TIME_COST = 2; + private static final int ARGON2ID_PARALLELISM = 1; + private static final int ARGON2ID_MIN_SALT_BYTES = 16; + + private static final SecureRandom RNG = new SecureRandom(); + + private final SecretKeySpec keySpec; + + private TokenCrypt(byte[] key) { + if (key == null) { + throw new IllegalArgumentException("key must not be null"); + } + if (key.length != AES_KEY_SIZE) { + throw new IllegalArgumentException( + "key must be exactly " + AES_KEY_SIZE + " bytes, got " + key.length); + } + this.keySpec = new SecretKeySpec(key.clone(), "AES"); + } + + /** + * Build a {@code TokenCrypt} from a raw 32-byte AES-256 key. + * + *

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 canonicalVectors() { + return Stream.of( + Arguments.of("a", "3ncr.org/1#I09Dwt6q05ZrH8GQ0cp+g9Jm0hD0BmCwEdylCh8"), + Arguments.of("test", "3ncr.org/1#Y3/v2PY7kYQgveAn4AJ8zP+oOuysbs5btYLZ9vl8DLc"), + Arguments.of( + "08019215-B205-4416-B2FB-132962F9952F", + "3ncr.org/1#pHRufQld0SajqjHx+FmLMcORfNQi1d674ziOPpG52hqW5+0zfJD91hjXsBsvULVtB017mEghGy3Ohj+GgQY5MQ"), + Arguments.of( + "перевірка", + "3ncr.org/1#EPw7S5+BG6hn/9Sjf6zoYUCdwlzweeB+ahBIabUD6NogAcevXszOGHz9Jzv4vQ")); + } + + private static TokenCrypt canonical() { + return TokenCrypt.fromRawKey(CANONICAL_KEY); + } + + private static byte[] randomKey() { + byte[] k = new byte[32]; + RNG.nextBytes(k); + return k; + } + + @ParameterizedTest + @MethodSource("canonicalVectors") + void decryptsCanonicalVector(String plaintext, String encrypted) { + assertEquals(plaintext, canonical().decryptIf3ncr(encrypted)); + } + + @ParameterizedTest + @MethodSource("canonicalVectors") + void roundTripsCanonicalPlaintext(String plaintext, String ignoredEncrypted) { + TokenCrypt tc = canonical(); + String enc = tc.encrypt3ncr(plaintext); + assertTrue(enc.startsWith(TokenCrypt.HEADER_V1), "should start with header"); + assertEquals(plaintext, tc.decryptIf3ncr(enc)); + } + + @Test + void roundTripsEdgeCases() { + TokenCrypt tc = TokenCrypt.fromRawKey(randomKey()); + StringBuilder longBuf = new StringBuilder(4096); + for (int i = 0; i < 4096; i++) { + longBuf.append('a'); + } + String[] cases = { + "", + "x", + "hello, world", + "08019215-B205-4416-B2FB-132962F9952F", + "перевірка 🌍 中文 ✓", + longBuf.toString(), + }; + for (String p : cases) { + String enc = tc.encrypt3ncr(p); + assertEquals(p, tc.decryptIf3ncr(enc)); + } + } + + @Test + void non3ncrReturnedUnchanged() { + TokenCrypt tc = TokenCrypt.fromRawKey(randomKey()); + String s = "plain config value"; + assertSame(s, tc.decryptIf3ncr(s)); + } + + @Test + void emptyStringReturnedUnchanged() { + TokenCrypt tc = TokenCrypt.fromRawKey(randomKey()); + String s = ""; + assertSame(s, tc.decryptIf3ncr(s)); + } + + @Test + void ivUniquenessAcrossEncryptions() { + TokenCrypt tc = TokenCrypt.fromRawKey(randomKey()); + String a = tc.encrypt3ncr("same plaintext"); + String b = tc.encrypt3ncr("same plaintext"); + assertNotEquals(a, b); + } + + @Test + void tamperedPayloadIsRejected() { + TokenCrypt tc = TokenCrypt.fromRawKey(randomKey()); + String enc = tc.encrypt3ncr("sensitive value"); + String body = enc.substring(TokenCrypt.HEADER_V1.length()); + char[] chars = body.toCharArray(); + int idx = chars.length / 2; + chars[idx] = (chars[idx] == 'A') ? 'B' : 'A'; + String tampered = TokenCrypt.HEADER_V1 + new String(chars); + assertThrows(TokenCryptException.class, () -> tc.decryptIf3ncr(tampered)); + } + + @Test + void truncatedPayloadIsRejected() { + TokenCrypt tc = TokenCrypt.fromRawKey(randomKey()); + TokenCryptException ex = assertThrows( + TokenCryptException.class, + () -> tc.decryptIf3ncr(TokenCrypt.HEADER_V1 + "AAAA")); + assertTrue(ex.getMessage().contains("truncated"), "message should mention truncated"); + } + + @Test + void decoderAcceptsPaddedInput() { + TokenCrypt tc = canonical(); + String plaintext = "a"; + String encrypted = "3ncr.org/1#I09Dwt6q05ZrH8GQ0cp+g9Jm0hD0BmCwEdylCh8"; + String body = encrypted.substring(TokenCrypt.HEADER_V1.length()); + int padCount = (4 - body.length() % 4) % 4; + StringBuilder sb = new StringBuilder(TokenCrypt.HEADER_V1).append(body); + for (int i = 0; i < padCount; i++) { + sb.append('='); + } + assertEquals(plaintext, tc.decryptIf3ncr(sb.toString())); + } + + @Test + void encoderEmitsNoPadding() { + TokenCrypt tc = TokenCrypt.fromRawKey(randomKey()); + String enc = tc.encrypt3ncr("some value"); + assertTrue(!enc.contains("="), "encoded output must not contain base64 padding"); + } + + @Test + void fromSha3RoundTrip() { + TokenCrypt tc = TokenCrypt.fromSha3("some-high-entropy-api-token"); + String enc = tc.encrypt3ncr("hello"); + assertEquals("hello", tc.decryptIf3ncr(enc)); + } + + @Test + void fromSha3BytesAndStringAgree() { + String secret = "some-high-entropy-api-token"; + TokenCrypt a = TokenCrypt.fromSha3(secret); + TokenCrypt b = TokenCrypt.fromSha3(secret.getBytes(StandardCharsets.UTF_8)); + String enc = a.encrypt3ncr("hello"); + assertEquals("hello", b.decryptIf3ncr(enc)); + } + + @Test + void fromArgon2idRoundTrip() { + TokenCrypt tc = TokenCrypt.fromArgon2id( + "correct horse battery staple", + "0123456789abcdef".getBytes(StandardCharsets.UTF_8)); + canonicalVectors().forEach(args -> { + String p = (String) args.get()[0]; + String enc = tc.encrypt3ncr(p); + assertEquals(p, tc.decryptIf3ncr(enc)); + }); + } + + @Test + void fromArgon2idRejectsShortSalt() { + assertThrows( + IllegalArgumentException.class, + () -> TokenCrypt.fromArgon2id("secret", "short".getBytes(StandardCharsets.UTF_8))); + } + + @Test + void fromArgon2idWrongSecretFailsToDecrypt() { + byte[] salt = "0123456789abcdef".getBytes(StandardCharsets.UTF_8); + TokenCrypt right = TokenCrypt.fromArgon2id("right secret", salt); + TokenCrypt wrong = TokenCrypt.fromArgon2id("wrong secret", salt); + String enc = right.encrypt3ncr("hello"); + assertThrows(TokenCryptException.class, () -> wrong.decryptIf3ncr(enc)); + } + + @Test + void fromRawKeyRejectsWrongLength() { + assertThrows(IllegalArgumentException.class, () -> TokenCrypt.fromRawKey(new byte[31])); + assertThrows(IllegalArgumentException.class, () -> TokenCrypt.fromRawKey(new byte[33])); + assertThrows(IllegalArgumentException.class, () -> TokenCrypt.fromRawKey(new byte[0])); + } + + @Test + void rawKeyInputIsDefensivelyCopied() { + byte[] key = randomKey(); + byte[] original = Arrays.copyOf(key, key.length); + TokenCrypt tc = TokenCrypt.fromRawKey(key); + Arrays.fill(key, (byte) 0); + String enc = assertDoesNotThrow(() -> tc.encrypt3ncr("hello")); + TokenCrypt same = TokenCrypt.fromRawKey(original); + assertEquals("hello", same.decryptIf3ncr(enc)); + } +}