Introducing Jagged for age Encryption in Java
Background
Filippo Valsorda and Ben Cartwright-Cox created the age specification in 2019 as a modern solution for file encryption. Pronounced with a hard g, age presents a modern alternative to the complexities and challenges of OpenPGP and GnuPG. The age specification avoids the problems of algorithm negotiation through the use of ChaCha20-Poly1305 as the single supported cipher for authenticated encryption of file payload information. Although supporting extensible encryption key exchange strategies, age specifies X25519 as the standard for asymmetric key encryption, and scrypt as the standard for password-based key derivation.
With a reference implementation in Go and an interoperable implementation in
Rust, age
is available as a command on all modern operating systems. Although the age
specification does not have three decades of evaluation, it is not encumbered with historical compatibility concerns.
The authors of age also created Community Cryptography Test Vectors, providing an
extensive set of test cases for validating implementations regardless of programming language or platform. With this
foundation, age presents a straightforward and compelling alternative to OpenPGP for file encryption.
Introduction
Jagged is a set of Java libraries implementing the age encryption specification. The modular architecture provides composable support for streaming age encryption using Java NIO Buffers and Channels.
Jagged relies on the Java Cryptography Architecture framework for ChaCha20-Poly1305 encryption and decryption, as well as for X25519 elliptic curve key negotiation. Delegating the majority of cryptographic operations to the JCA framework enables broad compatibility across Java runtime environments, and avoids coupling Jagged to particular libraries. The initial release of Jagged supports Java 11 and 17 without any additional dependencies, and also runs on Java 8 using an additional Security Provider. Jagged incorporates extensive test code coverage and complete compliance with age test vectors.
Implementation Strategy
The primary goal of Jagged is supporting age encryption and decryption on the Java platform. With modern Java versions providing the essential cryptographic algorithm requirements, Jagged uses standard libraries to implement the age specification. The modular architecture of Jagged supports integration scoped to application requirements. An opinionated API and strong encapsulation of framework components reduce the potential for integration issues while providing clear points for extension.
Strong Encapsulation
The interface design and packaging structure of Jagged modules abstract the majority of implementation details so that integrators can focus on encrypting and decrypting content streams. The jagged-api module contains the core public interfaces necessary for integration. The jagged-framework module contains a large number of interfaces and classes for interacting with the age specification, but most of these components have visibility restricted the same package level. This approach supports both targeted unit tests and minimal component wiring for integrating applications.
The jagged-scrypt and
jagged-x25519 modules provide concrete
implementations of the standard recipient interfaces. Automated services mandating asymmetric key exchange do not need
to include the jagged-scrypt
dependency for password-based encryption. Public factory classes in both modules abstract
the configuration of supporting components, streamlining the integration process.
Scoped Algorithms
Java 11 introduced a number of important security advances, including support for ChaCha20-Poly1305 encryption as described in JEP 329, and support for X25519 key agreement as outlined in JEP 324. Alternative Java Security Providers such as Bouncy Castle support these algorithms on Java 8.
For less common capabilities such as Bech32 encoding and RFC 7914 scrypt key derivation, Jagged includes packaged implementations with standard test vectors. Jagged also implements RFC 5869 HMAC-based key derivation using standard Java message authentication code capabilities, with extract and expand functions tailored to the requirements of the age specification.
Integration Overview
Jagged does not provide a command line interface, but comparing component functionality to age commands is a helpful way to illustrate library features. Building on the design of the age specification for streaming encryption and decryption, Jagged uses Java NIO components to provide efficient memory handling. This approach allows integrating services to process multiple gigabytes of information with small heap sizes.
Although standard implementations of Java NIO Channel do not include memory-based options, the Channels class provides several methods for wrapping streams that can support operations in memory. Holding content in memory should be avoided except in cases where the maximum file size is known and enforced.
Dependency Configuration
Jagged consists of discrete modules to differentiate features and support fine-grained capability selection. The
jagged-x25519
and jagged-scrypt
modules provide the corresponding recipient type implementations. Both modules have
transitive dependencies on the jagged-api
and jagged-framework
modules. Declaring dependencies on these four modules
enables integration with standard recipients.
The following Maven declarations provide an example for project integration.
<dependencies>
<dependency>
<groupId>org.exceptionfactory.jagged</groupId>
<artifactId>jagged-scrypt</artifactId>
<version>0.1.0</version>
</dependency>
<dependency>
<groupId>org.exceptionfactory.jagged</groupId>
<artifactId>jagged-x25519</artifactId>
<version>0.1.0</version>
</dependency>
<dependency>
<groupId>org.exceptionfactory.jagged</groupId>
<artifactId>jagged-framework</artifactId>
<version>0.1.0</version>
</dependency>
<dependency>
<groupId>org.exceptionfactory.jagged</groupId>
<artifactId>jagged-api</artifactId>
<version>0.1.0</version>
</dependency>
</dependencies>
The jagged-bech32
module is a transitive dependency of jagged-x25519
, but direct references are not required for
standard integration.
Key Pair Generation
The age-keygen command is a natural starting point for age encryption operations. The command supports generating public and private key pairs for X25519 recipients.
Running the command without any arguments generates and prints both the public key and private key.
$ age-keygen
# created: 2021-01-02T15:30:45+01:00
# public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
Both the public key and private key contain string representations of compressed elliptic points according to the
Curve25519 standard. The string representations use
Bech32 encoding, which Bitcoin adopted as a standard address format described in
BIP 0173. With a standard human-readable part, a restricted character set, and an
embedded checksum, Bech32 provides a number of useful properties for representing otherwise random key material. For age
encryption, encoded public keys always begin with age1
and encoded private keys always begin with AGE-SECRET-KEY-1
,
with the key material and checksum following.
The Jagged
X25519KeyPairGenerator
provides capabilities similar to the default age-keygen
command. The class extends the standard
java.security.KeyPairGenerator
abstract class and supports generating encoded key pairs.
The following Java class generates a key pair and prints the encoded keys to the console output stream.
import com.exceptionfactory.jagged.x25519.X25519KeyPairGenerator;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
public class JaggedKeyGenCommand {
public static void main(final String[] args) throws NoSuchAlgorithmException {
final KeyPairGenerator generator = new X25519KeyPairGenerator();
final KeyPair keyPair = generator.generateKeyPair();
System.out.printf("# created: %s%n", Instant.now());
System.out.printf("# public key: %s%n", keyPair.getPublic());
System.out.printf("%s%n", keyPair.getPrivate());
}
}
Integrating services should store generated private keys in a secure location with restricted permissions. The private key class implements the Destroyable interface to enable clearing encoded bytes.
Asymmetric Key Encryption
Encryption with an asymmetric key pair requires the public key specified as the recipient for protected information.
The age command uses the --encrypt
argument for encryption operations and the
--recipient
argument to specify the recipient public key. The --output
argument specifies the encrypted output file
location. All arguments support a single letter alternative to the full argument label. The command accepts input from a
file path or piped from another command.
Running the command writes the encrypted binary to the specified path and does not print information to the console.
$ age -e -r age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z -o data.age data
Encryption operations in Jagged require implementations of the RecipientStanzaWriter and EncryptingChannelFactory interfaces.
Recipient Stanza Writer
The RecipientStanzaWriter
is responsible for processing a
File Key and returning one or more
Recipient Stanzas containing the arguments necessary
for reconstructing the File Key. For standard age recipient types, a Recipient Stanza contains the File Key encrypted
using ChaCha20-Poly1305. For X25519 recipient types, the Recipient Stanza contains an ephemeral shared key.
The
X25519RecipientStanzaWriterFactory
contains a static method named newRecipientStanzaWriter
for creating a RecipientStanzaWriter
from an encoded public
key.
Encrypting Channel Factory
The EncryptingChannelFactory
abstracts creation of a
WritableByteChannel
that encrypts supplied information to specified recipients. Implementations of EncryptingChannelFactory
perform
buffering of supplied bytes, and write encrypted segments to the specified output channel. Following the age
specification, the input buffer holds up to 64 kilobytes. The actual standard size of an encrypted chunk is 65,552
bytes, which includes 65,536 encrypted bytes plus 16 bytes for the authenticated encryption tag generated as part of
ChaCha20-Poly1305 processing.
The jagged-framework
module includes the
StandardEncryptingChannelFactory
implementation, which supports writing files using binary encoding.
Binary Encryption with X25519
The following Java class uses the X25519 recipient type to write encrypted files using binary encoding. The input path and output path locations are relative to the current working directory.
import com.exceptionfactory.jagged.EncryptingChannelFactory;
import com.exceptionfactory.jagged.RecipientStanzaWriter;
import com.exceptionfactory.jagged.framework.stream.StandardEncryptingChannelFactory;
import com.exceptionfactory.jagged.x25519.X25519RecipientStanzaWriterFactory;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.GeneralSecurityException;
import java.util.Collections;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.nio.file.StandardOpenOption.WRITE;
public class JaggedEncryptCommand {
private static final int PLAIN_CHUNK_SIZE = 65536;
public static void main(final String[] args) throws GeneralSecurityException, IOException {
final RecipientStanzaWriter writer = newStanzaWriter();
final EncryptingChannelFactory channelFactory = new StandardEncryptingChannelFactory();
encrypt(writer, channelFactory);
}
private static RecipientStanzaWriter newStanzaWriter() throws GeneralSecurityException {
final CharSequence publicKey = "age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z";
return X25519RecipientStanzaWriterFactory.newRecipientStanzaWriter(publicKey);
}
private static void encrypt(
final RecipientStanzaWriter writer,
final EncryptingChannelFactory channelFactory
) throws GeneralSecurityException, IOException {
final Path inputPath = Paths.get("data");
final Path outputPath = Paths.get("data.age");
try (
final ReadableByteChannel inputChannel = Files.newByteChannel(inputPath);
final WritableByteChannel encryptingChannel = channelFactory.newEncryptingChannel(
Files.newByteChannel(outputPath, CREATE, WRITE),
Collections.singletonList(writer)
)
) {
copy(inputChannel, encryptingChannel);
}
}
private static void copy(
final ReadableByteChannel inputChannel,
final WritableByteChannel outputChannel
) throws IOException {
final ByteBuffer buffer = ByteBuffer.allocate(PLAIN_CHUNK_SIZE);
while (inputChannel.read(buffer) != -1) {
buffer.flip();
while (buffer.hasRemaining()) {
outputChannel.write(buffer);
}
buffer.clear();
}
buffer.flip();
while (buffer.hasRemaining()) {
outputChannel.write(buffer);
}
}
}
The main
method creates the required RecipientStanzaWriter
from the public key specified and also constructs the
EncryptingChannelFactory
in preparation for encryption.
The encrypt
method uses a standard try-with-resources statement to ensure closing of both input and output files after
completion. Closing the encrypting WritableByteChannel
is required to ensure that the class writes the final chunk
to the output channel. Exception handling strategies differ depending on application requirements, but it is important
to note that the newEncryptingChannel
method can throw either a GeneralSecurityException
an IOException
depending
on failures encountered. Problems writing to the output file channel could result in an IOException
and failures to
perform encryption operations could throw a GeneralSecurityException
. Distinguishing between these types of exceptions
can be useful for troubleshooting.
The copy
method uses a
ByteBuffer to read chunks of
64 kilobytes from the input channel for writing to the encrypting output channel. The loop continues until reaching the
end of the input channel.
Asymmetric Key Decryption
Decryption operations require a private key identity corresponding to the recipient public key.
The age
command uses the --decrypt
argument for decryption and the --identity
argument to specify the file path
containing the private key. The decryption operation follows the same approach as encryption, with --output
indicating
the location for decrypted information, and the input file path specified as the last argument for the command.
Running the command reads the private key identity and the encrypted binary in order to write decrypted information.
$ age -d -i key.txt -o data data.age
Jagged decryption processing requires implementations of RecipientStanzaReader and DecryptingChannelFactory interfaces.
Recipient Stanza Reader
The RecipientStanzaReader
functions as the age identity wrapper, processing Recipient Stanza arguments and returning
the decrypted File Key. The
X25519RecipientStanzaReaderFactory
includes a static method named newRecipientStanzaReader
for initializing and returning a RecipientStanzaReader
from
a private key encoded using Bech32.
Decrypting Channel Factory
The DecryptingChannelFactory
interface defines creation of a
ReadableByteChannel
capable of streaming decryption from a supplied input channel. Based on the standard encrypted chunk size for age files,
the decrypting channel reads encrypted blocks into a buffer of 65,552 bytes and writes decrypted bytes to a buffer of
65,536 bytes.
The
StandardDecryptingChannelFactory
implementation in jagged-framework
is capable of reading binary files encrypted according to the age specification.
Binary Decryption with X25519
The following Java class provides an example of decryption using an X25519 private key identity. The file path references are relative to the current working directory.
import com.exceptionfactory.jagged.DecryptingChannelFactory;
import com.exceptionfactory.jagged.RecipientStanzaReader;
import com.exceptionfactory.jagged.framework.stream.StandardDecryptingChannelFactory;
import com.exceptionfactory.jagged.x25519.X25519RecipientStanzaReaderFactory;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.GeneralSecurityException;
import java.util.Collections;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.nio.file.StandardOpenOption.WRITE;
public class JaggedDecryptCommand {
private static final int PRIVATE_KEY_LENGTH = 74;
private static final int ENCRYPTED_CHUNK_SIZE = 65552;
public static void main(final String[] args) throws GeneralSecurityException, IOException {
final RecipientStanzaReader reader = newStanzaReader();
final DecryptingChannelFactory channelFactory = new StandardDecryptingChannelFactory();
decrypt(reader, channelFactory);
}
private static RecipientStanzaReader newStanzaReader() throws GeneralSecurityException, IOException {
final Path keyPath = Paths.get("key.txt");
final byte[] keyFileBytes = Files.readAllBytes(keyPath);
final ByteBuffer keyFileBuffer = ByteBuffer.wrap(keyFileBytes);
final CharBuffer keyFileDecoded = StandardCharsets.US_ASCII.decode(keyFileBuffer);
final CharSequence privateKey = keyFileDecoded.subSequence(0, PRIVATE_KEY_LENGTH);
return X25519RecipientStanzaReaderFactory.newRecipientStanzaReader(privateKey);
}
private static void decrypt(
final RecipientStanzaReader reader,
final DecryptingChannelFactory channelFactory
) throws GeneralSecurityException, IOException {
final Path inputPath = Paths.get("data.age");
final Path outputPath = Paths.get("data");
try (
final ReadableByteChannel decryptingChannel = channelFactory.newDecryptingChannel(
Files.newByteChannel(inputPath),
Collections.singletonList(reader)
);
final WritableByteChannel outputChannel = Files.newByteChannel(outputPath, CREATE, WRITE)
) {
copy(decryptingChannel, outputChannel);
}
}
private static void copy(
final ReadableByteChannel inputChannel,
final WritableByteChannel outputChannel
) throws IOException {
final ByteBuffer buffer = ByteBuffer.allocate(ENCRYPTED_CHUNK_SIZE);
while (inputChannel.read(buffer) != -1) {
buffer.flip();
while (buffer.hasRemaining()) {
outputChannel.write(buffer);
}
buffer.clear();
}
buffer.flip();
while (buffer.hasRemaining()) {
outputChannel.write(buffer);
}
}
}
The main
method constructs a RecipientStanzaReader
from the private key identity contained in key.txt
and also
creates a DecryptingChannelFactory
for subsequent decryption processing.
The method for reading the private key identity expects the file to contain the Bech32 string on the first line. The age
private key identity consists of 74 characters beginning with AGE-SECRET-KEY-1
, so the method reads the first 74 bytes
from the file, avoiding any trailing newlines or additional information. The age
command can read multiple lines and
ignore commented lines, so this method could be adjusted for greater flexibility when parsing.
The decrypt
method follows a pattern similar to the encrypt
method in the preceding example class. The
newDecryptingChannel
method expects an input channel and a collection of one or more RecipientStanzaReader
instances
for reconstructing the File Key. The copy
method uses a ByteBuffer
of 65,552 bytes to accommodate up to one
encrypted chunk. In practice, the exact size of the buffer can be somewhat smaller or larger, with the main goal of
balancing memory consumption and channel read invocations.
Passphrase Encryption
Encryption with a passphrase uses a string of one or more characters as the source for a derived key. The age
specification relies on the scrypt
algorithm for key derivation based on its combination of both processing cycles and
memory consumption to slow down automated cracking attempts. RFC 7914
codified the scrypt function based the original design that Colin Percival
created for Tarsnap.
The age specification for the
scrypt recipient type uses a block size
factor of 8 and a parallelization factor 1 together with a configurable work factor for key derivation. The scrypt
algorithm requires the cost factor to be a power of 2, so the age specification defines the work factor argument as the
base-two logarithm of the scrypt cost factor. The age
command uses a default work factor of 18
to produce an
scrypt cost factor of 262144
, which requires around one second of processing on current commodity hardware.
The age
command uses the --passphrase
argument to prompt for a passphrase for both encryption and decryption.
Running the command requires an interactive response to enter and confirm the passphrase.
$ age -e -p -o data.age data
Jagged encryption with a passphrase uses the standard EncryptingChannelFactory
interface and implementation, but
depends on the
ScryptRecipientStanzaWriterFactory
for creating new RecipientStanzaWriter
instances. The newRecipientStanzaWriter
method requires a byte array
containing the passphrase, and a work factor of 2 or greater. Using a work factor of 18
follows the convention of the
age
command. The scrypt implementation writes the work factor as an argument in the recipient stanza of the age file
header.
Passphrase Decryption
Decryption with a passphrase is similar to the encryption process, using the same scrypt algorithm for key derivation.
The age specification does not allow scrypt recipients to be combined with other types. The age
command disallows the
--decrypt
argument when providing the --passphrase
argument.
As with encryption, running the command requires an interactive response to enter and confirm the passphrase.
$ age -p -o data data.age
Passphrase decryption with Jagged uses the
ScryptRecipientStanzaReaderFactory
for creating new RecipientStanzaReader
instances. With the scrypt recipient stanza containing the required work factor
for key derivation, the newRecipientStanzaReader
method is limited to requiring the passphrase byte array.
The Jagged scrypt implementation limits the maximum work factor to 20. This limits the potential for inordinate work factor values to consume system resources.
ASCII Armored Encoding
The standard age file format uses binary encoding for encrypted payloads. For use cases with limited character sets, age also defines an ASCII armor format based on strict PEM headers and footers with content encoding using Base64. Files encoded with Base64 are at least twenty-five percent larger than binary files, and require additional processing to convert between bytes and corresponding ASCII characters.
The age
command uses the --armor
argument with encryption to select ASCII armored encoding. The command does not
require the --armor
for decryption and is capable of detecting ASCII armored or binary encoding.
Jagged supports ASCII armored encoding using alternative implementations of the EncryptingChannelFactory
and
DecryptingChannelFactory
interfaces. The
ArmoredEncryptingChannelFactory
and
ArmoredDecryptingChannelFactory
classes extend the standard implementations, providing direct replacement for standard factories when ASCII encoding
or decoding is required. The decrypting implementation does not perform encoding detection.
Alternative Security Providers
With support for ChaCha20-Poly1305 and X25519 in Java 11 and following, the default JDK providers enable Jagged to run without additional dependencies. Running Jagged on Java 8 requires following one of several approaches to configure an alternative Provider implementation that supports those algorithms.
The BouncyCastleProvider implementation packaged in bcprov-jdk18on has supported both ChaCha20-Poly1305 and X25519 since version 1.63.
The Google Conscrypt library supports ChaCha20-Poly1305 but does not support X25519 as of version 2.5.2. The current development branch has incorporated X25519 key agreement so that subsequent releases should provide all algorithms necessary for Jagged integration.
Provider Arguments
Public components in Jagged support optional constructor or method arguments to pass a Provider
instance for
initializing cryptographic features.
Both the StandardEncryptingChannelFactory
and ArmoredEncryptingChannelFactory
implementations have alternative
constructors that accept a Provider
object supporting the ChaCha20-Poly1305
cipher algorithm.
The DecryptingChannelFactory
implementations have the same option.
The X25519 and scrypt recipient stanza implementations also require ChaCha20-Poly1305
cipher capabilities. As the name
implies, the X25519 implementation requires a Provider
that supports X25519
for key agreement and key pair
generation.
Supplying a Provider
as an argument to Jagged components enables fine-grained control over cryptographic operations
without impacting other libraries.
Provider Registration
Rather than coupling individual operations to a specific Provider
, the
java.security.Security class
supports runtime registration. The Security.addProvider()
method appends a new Provider
to existing registrations,
enabling subsequent cryptographic operations to use supported algorithms.
This approach enables Jagged integration on Java 8 without specifying the Provider
as an argument for constructors or
methods. Runtime registration is not suitable for environments with restrictive policies. This approach impacts all
subsequent cryptographic operation, so it should be limited to runtimes where multiple applications do not share the
Java Virtual Machine.
Security Configuration
Overriding the global Java security configuration is another option that avoids introducing code changes for alternative security providers. Similar to runtime registration, changing the Java security configuration impacts all cryptographic operations.
Although it is possible to change the java.security
configuration located in the Java installation directory,
upgrading Java versions requires maintaining those modifications. Setting the java.security.properties
Java System
property enables selective overriding of security properties listed in the configuration file referenced in the System
property.
The contents of a custom Java security properties must include a property name starting with security.provider
along
with a priority number. For example, setting security.provider.1
overrides the default Java security provider with the
class specified. The security provider property value must be a Java class.
Conclusion
Cryptographic components often come with sharp edges. The world of cryptographic engineering presents a number of challenges, from algorithm selection to platform capabilities. Poor integration can undermine good design decisions. Developing a secure system requires much more than using modern algorithms, it also requires careful consideration of inputs and outputs. The authors of age designed the specification with the specific use case of file encryption, which is important to remember when considering potential paths of integration.
As a set of Java libraries, Jagged provides the building blocks for integrating the age specification in various applications. Although Jagged and supporting algorithms provide important security properties, integrating age encryption comes with the responsibility to evaluate the full scope of potential threats.
With widespread availability and a growing ecosystem of integrations, the age specification provides a solid answer to the question of interoperable file encryption. Jagged brings interoperation to the Java platform, providing a foundation for new products and services.