Java bindings (JNI) for Asherah envelope encryption with automatic key rotation. Published to Maven Central.
<dependency>
<groupId>com.godaddy.asherah.encryption</groupId>
<artifactId>appencryption</artifactId>
<version>0.50.0</version>
</dependency>No extra Maven repository configuration is required when using Maven Central.
dependencies {
implementation 'com.godaddy.asherah.encryption:appencryption:0.50.0'
}The package includes prebuilt native JNI libraries for Linux x64/ARM64 (glibc and musl/Alpine), macOS x64/ARM64, and Windows x64/ARM64. The matching binary is auto-extracted from the JAR to your temp directory on first use — no java.library.path configuration is required.
-Dasherah.java.nativeLibraryPath=<file-or-dir>system property — if set, this exact path (or directory) is used and the JAR-bundled binaries are ignored.ASHERAH_JAVA_NATIVE=<file-or-dir>environment variable — same as above, lower precedence than the system property.- Bundled JAR resource for the detected platform RID (
linux-{x86_64,aarch64},linux-musl-{x86_64,aarch64},darwin-{x86_64,aarch64},windows-{x86_64,aarch64}) extracted to a content-addressed directory. Extraction base-dir search order:-Dasherah.java.nativeExtractDir=<dir>(orASHERAH_JAVA_NATIVE_EXTRACT_DIR)${java.io.tmpdir}${user.home}/.cache/asherah-jni${user.home}/.asherah-jni${user.dir}/.asherah-jniConcurrent JVMs share the same content-addressed cache path; version upgrades land in a fresh directory.
System.loadLibrary("asherah_java")— falls back to the JVM'sjava.library.path.
On SELinux-enforcing/noexec environments (common on hardened RHEL), loading from
/tmp may fail even when extraction succeeds. The loader automatically retries
alternate directories above. You can force an exec-allowed location via:
-Dasherah.java.nativeExtractDir=/opt/app/lib.
Task-oriented walkthroughs under docs/:
| Guide | When to read |
|---|---|
| Getting started | Maven/Gradle through round-trip encrypt/decrypt. |
| Framework integration | Spring Boot, Micronaut, Quarkus, Helidon, Vert.x, servlets. |
| AWS production setup | KMS keys, DynamoDB, IAM policy, region routing. |
| Testing | JUnit 5 fixtures, Spring Boot tests, Mockito patterns, Testcontainers. |
| Troubleshooting | Common errors with what to check first. |
This artifact intentionally supports two Java API styles:
- Compat API (legacy shape):
com.godaddy.asherah.appencryption.* - New JNI API (recommended):
com.godaddy.asherah.jni.*
Both are backed by the same native Rust core and write the same metastore
records. The compat API exists for drop-in migration from canonical
godaddy/asherah-java codebases, while the JNI API is the preferred surface
for new development.
| Goal | Use this API |
|---|---|
| Minimize application code changes from canonical SDK | com.godaddy.asherah.appencryption.* |
| Explicit lifecycle, clearer config, richer async/logging/metrics hooks | com.godaddy.asherah.jni.* |
The simplest way to use Asherah is the static API on the Asherah class. Call setup() once at startup and shutdown() on exit:
import com.godaddy.asherah.jni.Asherah;
import com.godaddy.asherah.jni.AsherahConfig;
AsherahConfig config = AsherahConfig.builder()
.serviceName("my-service")
.productId("my-product")
.metastore("memory") // testing only
.kms("static") // testing only
.enableSessionCaching(Boolean.TRUE)
.build();
Asherah.setup(config);
try {
String ciphertext = Asherah.encryptString("partition-id", "sensitive data");
String plaintext = Asherah.decryptString("partition-id", ciphertext);
} finally {
Asherah.shutdown();
}The static API manages a session cache internally. Sessions are created on first use per partition and reused for subsequent calls.
For direct control over session lifecycle, use AsherahFactory and AsherahSession:
import com.godaddy.asherah.jni.Asherah;
import com.godaddy.asherah.jni.AsherahConfig;
import com.godaddy.asherah.jni.AsherahFactory;
import com.godaddy.asherah.jni.AsherahSession;
AsherahConfig config = AsherahConfig.builder()
.serviceName("my-service")
.productId("my-product")
.metastore("memory") // testing only
.kms("static") // testing only
.build();
try (AsherahFactory factory = Asherah.factoryFromConfig(config)) {
try (AsherahSession session = factory.getSession("partition-id")) {
byte[] ciphertext = session.encryptBytes("sensitive data".getBytes());
byte[] plaintext = session.decryptBytes(ciphertext);
// String convenience methods
String ct = session.encryptString("sensitive data");
String pt = session.decryptString(ct);
}
}Both AsherahFactory and AsherahSession implement AutoCloseable and are backed by a Cleaner for safety-net finalization.
Every encrypt/decrypt method has a CompletableFuture variant.
CompletableFuture<byte[]> ct = Asherah.encryptAsync("partition", plaintext);
CompletableFuture<byte[]> pt = Asherah.decryptAsync("partition", ct.get());
// String variants
CompletableFuture<String> ctStr = Asherah.encryptStringAsync("partition", "data");
CompletableFuture<String> ptStr = Asherah.decryptStringAsync("partition", ctStr.get());try (AsherahSession session = factory.getSession("partition-id")) {
CompletableFuture<byte[]> ct = session.encryptBytesAsync(plaintext);
CompletableFuture<byte[]> pt = session.decryptBytesAsync(ct.get());
CompletableFuture<String> ctStr = session.encryptStringAsync("data");
CompletableFuture<String> ptStr = session.decryptStringAsync(ctStr.get());
}The session-level async methods (encryptBytesAsync, decryptBytesAsync, etc.) are true async -- the encrypt/decrypt work runs on Rust's tokio runtime and completes the CompletableFuture via JNI AttachCurrentThread. The calling Java thread is NOT blocked during the native operation.
The static-level async methods (Asherah.encryptAsync, Asherah.decryptAsync) dispatch to ForkJoinPool.commonPool() via CompletableFuture.supplyAsync().
Overhead: approximately 8 microseconds for async vs 1.1 microseconds for sync (64B hot cache). Use async when you need non-blocking behavior; use sync for lowest latency.
Partition ID (null, ""): always rejected as programming errors
with NullPointerException / IllegalArgumentException. No row is
ever written to the metastore under a degenerate partition ID.
(Canonical com.godaddy.asherah:appencryption v0.4.0 accepts null
partitions and persists _IK_null_service_product rows; this binding
is deliberately stricter.)
Plaintext to encrypt:
null→NullPointerException(sync) / rejectedCompletableFuture(async).- Empty
String("") andnew byte[0]are valid plaintexts.encrypt(...)produces a realDataRowRecordenvelope; the matchingdecrypt(...)returns exactly""ornew byte[0].
Ciphertext to decrypt:
null→NullPointerException.- Empty
String/byte[]→ runtime exception from JSON parse (not validDataRowRecord).
Do not short-circuit empty plaintext encryption in caller code — empty data is real data, encrypting it produces a genuine envelope, and skipping encryption leaks the fact that the value was empty. See docs/input-contract.md for the full rationale.
This replaces the canonical com.godaddy.asherah:appencryption (v0.3.3), which is a pure Java implementation using Protobuf serialization. This Rust-backed binding is wire-compatible (reads/writes the same metastore format) and significantly faster.
Key differences:
Canonical (appencryption) |
This binding (asherah-java) |
|
|---|---|---|
| Implementation | Pure Java + Protobuf | Rust + JNI |
| Serialization | Protobuf | JSON |
| Configuration | Builder pattern | AsherahConfig.builder() |
| Session model | AppEncryptionSessionFactory |
AsherahFactory / AsherahSession |
| Memory protection | None | memguard (locked, wiped pages) |
| Async support | None | CompletableFuture |
Migration steps:
- Update dependency coordinates to
com.godaddy.asherah.encryption:appencryption - Use a current version (for example
0.50.0or newer) - Choose either the native JNI API or the bundled compatibility classes:
- Use
com.godaddy.asherah.jni.AsherahFactory/AsherahSessionfor new code. - Keep
com.godaddy.asherah.appencryption.SessionFactoryfor a lower-change migration.
- Use
- Both read the same metastore tables -- no data migration required
If you choose to move from compat classes to the JNI API, use this mapping:
| Compat API | New JNI API |
|---|---|
SessionFactory.newBuilder(product, service) |
AsherahConfig.builder().productId(...).serviceName(...) + Asherah.factoryFromConfig(config) |
SessionFactory#getSessionJson(...) / #getSessionBytes(...) |
AsherahFactory#getSession(...) returning AsherahSession |
Session#encrypt(...) / Session#decrypt(...) |
`AsherahSession#encryptBytes |
JdbcMetastoreImpl / DynamoDbMetastoreImpl / InMemoryMetastoreImpl |
`AsherahConfig.metastore("rdbms" |
withStaticKeyManagementService(...) / withKeyManagementService(...) |
`AsherahConfig.kms("static" |
withCryptoPolicy(...) |
AsherahConfig key/session policy fields (expireAfter, checkInterval, sessionCache*, etc.) |
For teams that want minimal change now, staying on the compat API is supported.
For teams that want the clearest long-term path, migrate toward com.godaddy.asherah.jni.*.
Static KMS key configuration differs by API. The native
AsherahConfig.staticMasterKeyHex(...) field expects a 64-character hex
string encoding 32 bytes. The compat
withStaticKeyManagementService(String) path accepts the legacy raw key string
and converts it to hex before passing it to the native core.
When you use the compat API (com.godaddy.asherah.appencryption.*) with
JdbcMetastoreImpl, database operations still execute in the native Rust layer.
To preserve practical drop-in behavior, JDBC pool hints from compat usage are
mapped into native pool configuration:
| Compat input | Native config field |
|---|---|
withPoolMaxOpen(...) or DataSource max-open style getter |
PoolMaxOpen |
withPoolMaxIdle(...) or DataSource idle-size style getter |
PoolMaxIdle |
withPoolMaxLifetime(...) (seconds) or DataSource max-lifetime (ms) |
PoolMaxLifetime (seconds) |
withPoolMaxIdleTime(...) (seconds) or DataSource idle-timeout (ms) |
PoolMaxIdleTime (seconds) |
jdbc: URLs are also normalized before being passed to native config. For
example, jdbc:mysql://... is mapped to mysql://....
Benchmarked on Apple M4 Max, 64-byte payload, hot session cache:
| Operation | Latency |
|---|---|
| Encrypt | ~1,118 ns |
| Decrypt | ~974 ns |
All configuration is done through AsherahConfig.builder():
| Builder Method | Type | Required | Description |
|---|---|---|---|
serviceName |
String |
Yes | Service identifier for key hierarchy |
productId |
String |
Yes | Product identifier for key hierarchy |
metastore |
String |
Yes | "rdbms", "dynamodb", "memory" (testing) |
kms |
String |
No | "static" (default) or "aws" |
connectionString |
String |
No | RDBMS connection string |
dynamoDbEndpoint |
String |
No | Custom DynamoDB endpoint |
dynamoDbRegion |
String |
No | DynamoDB region |
dynamoDbSigningRegion |
String |
No | DynamoDB signing region |
dynamoDbTableName |
String |
No | DynamoDB table name |
regionMap |
Map<String, String> |
No | AWS KMS region-to-ARN map |
preferredRegion |
String |
No | Preferred AWS KMS region |
awsProfileName |
String |
No | AWS shared-credentials profile for KMS, DynamoDB, and Secrets Manager clients (native Rust SDK), e.g. from ~/.aws/credentials |
enableRegionSuffix |
Boolean |
No | Append region suffix to key IDs |
enableSessionCaching |
Boolean |
No | Enable session caching (default: true) |
sessionCacheMaxSize |
Integer |
No | Max cached sessions |
sessionCacheDuration |
Long |
No | Cache TTL in milliseconds |
expireAfter |
Long |
No | Key expiration in seconds |
checkInterval |
Long |
No | Key check interval in seconds |
replicaReadConsistency |
String |
No | DynamoDB read consistency |
verbose |
Boolean |
No | Enable verbose logging (default: false) |
poolMaxOpen |
Integer |
No | Max open DB connections (default: 0 = unlimited) |
poolMaxIdle |
Integer |
No | Max idle connections to retain (default: 2) |
poolMaxLifetime |
Long |
No | Max connection lifetime in seconds (default: 0 = unlimited) |
poolMaxIdleTime |
Long |
No | Max idle time per connection in seconds (default: 0 = unlimited) |
For AWS KMS, DynamoDB, or Secrets Manager, when awsProfileName is omitted the native Rust credential chain applies (including AWS_PROFILE and shared config files under ~/.aws/). Passing awsProfileName(...) selects a named profile explicitly.
You can also initialize from environment variables:
AsherahFactory factory = Asherah.factoryFromEnv();| Method | Description |
|---|---|
setup(AsherahConfig) |
Initialize the global factory |
setupAsync(AsherahConfig) |
Async variant of setup |
shutdown() |
Release all resources and cached sessions |
shutdownAsync() |
Async variant of shutdown |
getSetupStatus() |
Returns true if initialized |
encrypt(String, byte[]) |
Encrypt bytes, returns DRR JSON bytes |
encryptString(String, String) |
Encrypt string, returns DRR JSON string |
encryptAsync(String, byte[]) |
Async encrypt returning CompletableFuture<byte[]> |
encryptStringAsync(String, String) |
Async encrypt returning CompletableFuture<String> |
decrypt(String, byte[]) |
Decrypt DRR JSON bytes to plaintext bytes |
decryptString(String, String) |
Decrypt DRR JSON string to plaintext string |
decryptJson(String, String) |
Decrypt DRR JSON string to plaintext bytes |
decryptAsync(String, byte[]) |
Async decrypt returning CompletableFuture<byte[]> |
decryptStringAsync(String, String) |
Async decrypt returning CompletableFuture<String> |
factoryFromConfig(AsherahConfig) |
Create a standalone factory |
factoryFromEnv() |
Create a factory from environment variables |
setEnv(Map<String, String>) |
Set environment variables |
setEnvJson(String) |
Set environment variables from JSON string |
| Method | Description |
|---|---|
getSession(String) |
Create a session for a partition ID |
close() |
Release the factory (implements AutoCloseable) |
| Method | Description |
|---|---|
encryptBytes(byte[]) |
Encrypt bytes, returns DRR JSON bytes |
encryptString(String) |
Encrypt string, returns DRR JSON string |
encryptToJson(byte[]) |
Encrypt bytes, returns DRR JSON string |
encryptBytesAsync(byte[]) |
True async encrypt via tokio |
encryptStringAsync(String) |
True async encrypt, string variant |
decryptBytes(byte[]) |
Decrypt DRR JSON bytes to plaintext bytes |
decryptString(String) |
Decrypt DRR JSON string to plaintext string |
decryptFromJson(String) |
Decrypt DRR JSON string to plaintext bytes |
decryptBytesAsync(byte[]) |
True async decrypt via tokio |
decryptStringAsync(String) |
True async decrypt, string variant |
close() |
Release the session (implements AutoCloseable) |
Asherah ships first-class SLF4J integration. The simplest way to wire up
logging is to hand it an SLF4J Logger directly — the bridge dispatches each
record at its native severity, so any backend the host has bound SLF4J to
(Logback, Log4j 2, java.util.logging) receives it without translation.
import com.godaddy.asherah.jni.Asherah;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Logger asherahLog = LoggerFactory.getLogger("asherah");
Asherah.setLogHook(asherahLog);
// ... later
Asherah.clearLogHook();For per-target dispatch — each Rust source target (asherah::session,
asherah::builders, etc.) routed to its own Logger so host-side filter
rules can match by category — pass an ILoggerFactory through the
AsherahSlf4j helper:
import com.godaddy.asherah.jni.AsherahSlf4j;
import org.slf4j.LoggerFactory;
Asherah.setLogHook(AsherahSlf4j.logHook(LoggerFactory.getILoggerFactory()));The raw AsherahLogHook callback is also available if you don't use SLF4J:
import com.godaddy.asherah.jni.Asherah;
import com.godaddy.asherah.jni.AsherahLogHook;
import org.slf4j.event.Level;
Asherah.setLogHook(event -> {
// event.getLevel() returns org.slf4j.event.Level directly
if (event.getLevel().toInt() >= Level.WARN.toInt()) {
System.err.println("[asherah " + event.getLevel() + "] "
+ event.getTarget() + ": " + event.getMessage());
}
});The hook may fire from any thread (tokio worker threads, DB driver threads), so implementations must be thread-safe and should not block. Exceptions thrown from the hook are caught and silently swallowed by the JNI bridge.
Asherah produces timing observations (encrypt, decrypt, store, load)
and cache events (cache_hit, cache_miss, cache_stale). Installing a
hook implicitly enables the global metrics gate; clearing it disables it.
If your stack already uses Micrometer (Spring Boot, Micronaut, Quarkus, or any
OpenTelemetry / Prometheus / Datadog / CloudWatch registry), point the
metrics hook at your MeterRegistry:
import com.godaddy.asherah.jni.Asherah;
import com.godaddy.asherah.jni.AsherahMicrometer;
import io.micrometer.core.instrument.MeterRegistry;
MeterRegistry registry = ...; // injected by your framework
Asherah.setMetricsHook(AsherahMicrometer.metricsHook(registry));The bridge registers standard instruments on the supplied registry —
asherah.encrypt.duration (Timer), asherah.cache.hits (Counter, tag
cache=name), and so on — and forwards each event to the appropriate one.
The micrometer-core dependency is optional in the POM: callers that
don't reference AsherahMicrometer are not forced to pull it.
For other metrics libraries (StatsD, dropwizard-metrics, etc.) install a raw callback:
import com.godaddy.asherah.jni.Asherah;
Asherah.setMetricsHook(event -> {
switch (event.getTypeEnum()) {
case ENCRYPT:
case DECRYPT:
case STORE:
case LOAD:
myMetrics.timing("asherah." + event.getType(), event.getDurationNs());
break;
case CACHE_HIT:
case CACHE_MISS:
case CACHE_STALE:
myMetrics.counter("asherah." + event.getType(), event.getName());
break;
}
});
// ... later
Asherah.clearMetricsHook();| Event type | Duration | Name |
|---|---|---|
ENCRYPT |
elapsed ns | null |
DECRYPT |
elapsed ns | null |
STORE |
elapsed ns | null |
LOAD |
elapsed ns | null |
CACHE_HIT |
0 |
cache identifier |
CACHE_MISS |
0 |
cache identifier |
CACHE_STALE |
0 |
cache identifier |
The same threading caveats apply as for the log hook — implementations must be thread-safe and non-blocking, and exceptions are caught.
Licensed under the Apache License, Version 2.0.