Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
27 changes: 9 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
69 changes: 69 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org._3ncr</groupId>
<artifactId>tokencrypt</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>

<name>tokencrypt</name>
<description>Java implementation of the 3ncr.org v1 string encryption standard (AES-256-GCM).</description>
<url>https://3ncr.org/</url>

<licenses>
<license>
<name>MIT License</name>
<url>https://opensource.org/licenses/MIT</url>
<distribution>repo</distribution>
</license>
</licenses>

<scm>
<url>https://github.com/3ncr/tokencrypt-java</url>
<connection>scm:git:https://github.com/3ncr/tokencrypt-java.git</connection>
<developerConnection>scm:git:git@github.com:3ncr/tokencrypt-java.git</developerConnection>
</scm>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<maven.compiler.release>11</maven.compiler.release>
<bouncycastle.version>1.78.1</bouncycastle.version>
<junit.version>5.10.2</junit.version>
</properties>

<dependencies>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
188 changes: 188 additions & 0 deletions src/main/java/org/_3ncr/tokencrypt/TokenCrypt.java
Original file line number Diff line number Diff line change
@@ -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 <a href="https://3ncr.org/">3ncr.org</a> v1 string
* encryption standard.
*
* <p>The v1 envelope is {@code 3ncr.org/1#<base64(iv[12] || ciphertext || tag[16])>}
* 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.
*
* <p>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.
*
* <p>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).
*
* <p>{@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.
*
* <p>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);
}
}
18 changes: 18 additions & 0 deletions src/main/java/org/_3ncr/tokencrypt/TokenCryptException.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading