ExceptionFactory

Producing content that a reasonable developer might want to read

Introducing Jagged for age Encryption in Java

age Cryptography Encryption Security

2023-08-29 • 16 minute read • David Handermann

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.