跳转到内容

此页面尚未翻译成中文,以下为英文原文。

Crypto support

此内容尚不支持你的语言。

If you copied the example that used to be on this page, read the last section first.

Until 2.0.0 this page reproduced the source of StandardEncryptor for you to paste into your own project. That class is AES/ECB with the passphrase used as the raw key, and a file encrypted with it discloses which of its secrets are equal. It was never part of the library, so nothing was deprecated and nothing broke — but if it is in your code, it is worth an hour. What changed, and what to do about it.

Since 2.0.0 there are two ways of putting an encrypted value in a configuration, and they are not equivalent. Write the marker. The annotation stays for the configurations that already use it.

a marker, since 2.0.0 @EncryptedValue since 1.0.10
Where it is declared in the value on the method
A cipher is shipped yes, two of them no — you supply the class
fill() gets the secret yes no
A value referring to it gets the secret gets the cipher text
store() writes back the marker the cipher text

Two ciphers come with it, and which one you want is a question about people rather than about algorithms:

  • ${$aes-gcm::…} — one passphrase, which both writes and reads. The simple case.
  • ${$rsa-oaep::…} — a key pair, so that whoever adds a secret to the file cannot read the ones already there. See A key pair below.

A value can name what resolves it instead of holding its own text:

db.password = ${$aes-gcm::AAM0UBtPtHU9kZcgvqX673gZTlmMpp4RxRWoHOoDUGjJI2AYd1o9qYPK}
jdbc.url = jdbc:h2:mem:test?password=${db.password}

Register the handler with the passphrase — from wherever your application already keeps it — and create the configuration afterwards:

ConfigFactory.registerValueHandler(new AesGcmHandler(passphrase));
MyConfig cfg = ConfigFactory.create(MyConfig.class);
cfg.password(); // the secret
cfg.jdbcUrl(); // …?password=<the secret>, because expansion recurses into the value

Nothing goes on the interface. password() is an ordinary String method.

The passphrase never comes from the properties.

That would be circular — the secret protecting the file, kept in the file. You construct the handler and register it, the way a loader is registered, which is what lets the passphrase arrive from an environment variable, a mounted secret, a vault client or anywhere else your application already reads it from.

The tool is in the core jar and needs nothing else on the classpath:

$ printf 's3cr3t\nhunter2\n' | OWNER_PASSPHRASE='…' \
java -cp owner-2.0.0.jar org.aeonbits.owner.handlers.EncryptTool
${$aes-gcm::AAM0UBtPtHU9kZcgvqX673gZTlmMpp4RxRWoHOoDUGjJI2AYd1o9qYPK}
${$aes-gcm::AAM0UBtPtHU9kZcgvqX673gZTlkT++B4i4OY/U+ozDWUAM4GLcG2l1wW}

Run it with no OWNER_PASSPHRASE and it asks on the terminal, twice, without echo. Markers go to standard output and everything else to standard error, so > markers.txt collects markers and nothing else.

Neither the passphrase nor the values may be command-line arguments, and the tool refuses them there rather than accepting them: a command line stays in the shell history and is visible in ps to every user on the machine. --handler, --name, --iterations and --public-key are arguments, because none of them is secret — a public key least of all.

Encrypt a whole file’s worth of values in one run. Every value of one run shares a salt and gets its own IV, so reading them back costs one key derivation between them instead of one per property.

base64( iterations(4) | salt(16) | iv(12) | ciphertext | tag(16) ), where

  • AES-256/GCM with a 128-bit tag, so an edited value fails loudly rather than decrypting to something else;
  • a random IV per value, so two equal secrets do not produce equal cipher text;
  • PBKDF2-HMAC-SHA256 at 210,000 iterations — OWASP’s current guidance — over a passphrase of any length;
  • the whole header is passed to GCM as additional authenticated data, so it cannot be edited on its own.

The iteration count travels in the token so that raising it later leaves files already written readable. It is not a knob: no syntax offers it to whoever edits the file, and a token asking to be read with fewer than 100,000 iterations is refused rather than honoured.

A handler is registered under a name, and the name is what a value refers to. Register two:

ConfigFactory.registerValueHandler(new AesGcmHandler("aes-gcm-2025", current));
ConfigFactory.registerValueHandler(new AesGcmHandler("aes-gcm-2024", previous));
db.password = ${$aes-gcm-2025::…} # moved
api.token = ${$aes-gcm-2024::…} # not yet

Both are readable while the rotation is under way, and it proceeds one value at a time.

A key pair, so that writing a secret is not reading them all

Section titled “A key pair, so that writing a secret is not reading them all”

With one passphrase, whoever can add a value to the file can read every other value in it. Often that is fine. When it is not — a developer adding an API key, a CI job writing a generated password — a key pair separates the two permissions:

// the deployment, and only the deployment
ConfigFactory.registerValueHandler(
new RsaHandler(RsaHandler.privateKeyFrom(Paths.get("/etc/app/app.key"))));
$ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out app.key
$ openssl rsa -in app.key -pubout -out app.pub # this half is shareable
$ java -cp owner-2.0.0.jar org.aeonbits.owner.handlers.EncryptTool \
--public-key app.pub < values.txt
${$rsa-oaep::7VcoaAGAX+3tbyARpqJRCyZ4rwan5sbVRIdut15ZaTC/Oh1m9UulHn+Q…}

The public key goes to everyone who writes; the private key stays where the configuration is read. A handler holding only one half says so when asked for the other, which is the point rather than a limitation. publicKeyFrom also reads a CERTIFICATE block, which is what a keystore exports.

The construction is hybrid, because RSA cannot encrypt a value. RSA-2048 with OAEP takes about 190 bytes and then falls off a cliff — enough for a password, not for a certificate or a connection string. So a fresh AES-256 key is drawn per value, the value is encrypted with it under GCM, and RSA wraps the key:

base64( fingerprint(4) | wrapped key length(2) | wrapped key(n) | iv(12) | ciphertext | tag(16) )

The fingerprint is four bytes of SHA-256 over the RSA modulus, which both halves of a pair expose. It is not a security measure — a public key is public — it is a diagnosis for the mistake this arrangement invites: encrypting against the wrong public key, which the person doing it cannot notice, because they cannot read back what they wrote. Without it the deployment fails with “could not be decrypted”; with it, it names both key pairs.

OAEP is given its parameters explicitly.

Naming the transformation alone — RSA/ECB/OAEPWithSHA-256AndMGF1Padding — leaves MGF1 on SHA-1 in the JDK, which is self-consistent but is not what the name says and does not interoperate with an openssl that was told SHA-256. This handler passes an OAEPParameterSpec with SHA-256 on both sides.

Rotation works the same way, by name: rsa-2024 beside rsa-2025. A private key of 1024 bits is refused, and so is a pair whose halves do not belong together.

The library reads the envelope — the $, the name, the :: — and hands everything after the first :: to the handler as text. It owns the envelope and the handler owns the payload, so nothing about the mechanism is specific to cryptography:

public class FileHandler implements ValueHandler {
public String name() { return "file"; }
public String resolve(String path) {
try {
return new String(Files.readAllBytes(Paths.get(path)), UTF_8).trim();
} catch (IOException e) {
throw new IllegalArgumentException("Could not read " + path, e);
}
}
}
db.password = ${$file::/run/secrets/db_password}
api.token = ${$vault::secret/data/app:v2}

Two rules a handler must respect:

  • it must throw when it cannot answer, never return the empty string — for a password that is the worst available answer, and it is indistinguishable from success;
  • what it returns is not expanded again. A secret that happens to contain ${ is a secret, not a template.

There is no discovery on the classpath. A handler exists only because you registered it: a file format found on the classpath reads files that are already yours, while a handler found on the classpath would answer for the values inside them.

A marker naming a handler nobody registered is an error, not an empty string — a misspelt name has to fail loudly for exactly the same reason.

Available since 1.0.10 and unchanged. A @DecryptorClass can be given for a class or for a single property, and @EncryptedValue(SomeDecryptor.class) overrides the class-level one:

@DecryptorClass(MyDecryptor.class)
public interface Sample extends Config {
@EncryptedValue
String myEncryptedPassword1();
@EncryptedValue(AnotherDecryptor.class)
String myEncryptedPassword2();
}

The @DecryptorClass is a property of the configuration and not of one interface of it, so it is found wherever in the hierarchy it is written — on a base interface that a dozen configurations extend, for instance. Before 2.0.0 it was read off the interface handed to the ConfigFactory and nowhere else, not even its direct super-interfaces, and the failure was quiet: the method answered with the cipher text as stored, which is a string like any other. See where an annotation counts.

You supply the Decryptor; the library ships none for this path. It composes with the other annotations:

@Key("crypto.list")
@EncryptedValue
@Separator(",")
@DefaultValue("Pfzoiet5E5zN2/7tfgrGLQ==")
List<String> cryptoList();

A method may not carry both, and a @EncryptedValue whose value is a marker is refused when the configuration is created. Expansion runs first, so the marker would decrypt the value and the decryptor would then be handed the plain secret to decrypt a second time.

A value that refers to an encrypted one gets the cipher text

Section titled “A value that refers to an encrypted one gets the cipher text”

Reported since 2.0.0. Composing a value out of an @EncryptedValue property does not work, and it used to fail in silence:

crypto.password = tzH7IKLCVc0AC72fh5DiZA==
jdbc.url = jdbc:h2:mem:test?password=${crypto.password}
cfg.password(); // the secret — the method that declares @EncryptedValue decrypts
cfg.jdbcUrl(); // …?password=tzH7IKLCVc0AC72fh5DiZA== — the cipher text

The same password reads two ways depending on how it is asked for. The connection then fails with a wrong password, or the cipher text travels somewhere a secret was meant to go.

It is where the annotation is written, not a defect in the substitution. The properties hold the cipher text — they have to, or store() would write the file back decrypted — and decryption happens per method, chosen by the @EncryptedValue on it. A variable names a key, so the substitution has nothing to read a decryptor from and inserts what it finds.

Since 2.0.0 the library says so when the configuration is created, naming both keys and neither value:

WARNING: the value of 'jdbc.url' refers to 'crypto.password', which is declared
@EncryptedValue. […] Compose the value in Java from the method that
decrypts it.

and owner.strict turns that into a refusal. The marker is the cure: written crypto.password=${$aes-gcm::…}, the reference above resolves to the secret, because decryption became part of the expansion instead of being attached to a method.

The same is true of a converter, and there it cannot be fixed.

A @ConverterClass is not applied either when a value is read through a variable, and that half has no cure at all: a converter answers with a typed object, and there is no room for one inside a string. Decryption is text to text, so only the missing decryptor was a question of where the declaration sits.

Written down because each of these is a decision rather than an omission.

  • A passphrase or a key cannot come from the configuration. That would be the secret protecting the file, kept in the file. You construct the handler and register it, so the material arrives from wherever your application already reads it.
  • A handler is registered on the factory, and ConfigFactory is one object for the whole JVM. Registering a name replaces what was under it, for every configuration created afterwards. That is what makes a key rotation an edit rather than a redeployment, and it also means a library registering a handler is making a decision on behalf of its host application.
  • A deserialized configuration cannot decrypt. The passphrase and the private key are transient, on purpose: writing them out would put a secret in a file nobody chose to protect. A handler that comes back from deserialization says so rather than failing obscurely.
  • @EncryptedValue still does not reach fill(), and structurally cannot — it is declared on a method, and a property asked for by name has no method to read the declaration from. Only the marker fixes that, which is why it exists.
  • ${$rsa-oaep::…} is not sops or age. It encrypts values, one at a time, to one key pair. There is no multi-recipient support, no key rotation of the file as a whole, and no signature over it.
  • A token is not portable to another tool. The format is ours: openssl will not read one, and this will not read what openssl enc writes.
  • The terminal prompt of EncryptTool has no automated test. Reading a passphrase twice without echo, refusing an empty one and refusing two that differ are exercised by hand, because a JVM under a test runner never has a terminal. What is tested is the half that matters most: with the streams redirected and no OWNER_PASSPHRASE, the piped value is not mistaken for the passphrase.
AES-256 and an old Java 8

Before 8u161 the unlimited strength jurisdiction policy files were a separate download, and without them a JVM caps AES at 128 bits. AesGcmHandler checks for this when it is constructed and says so, rather than letting it surface later as a decryption failure — an environment problem wearing the costume of a cryptographic one. Every JDK since is unrestricted by default.

This page carried the full source of StandardEncryptor and invited you to copy it. If you did, you are running AES/ECB with your passphrase as the raw key, and that is worth changing.

It was never shipped. The class lives in the test suite, and only the Decryptor, Encryptor and the two abstract classes were ever released API — so there is nothing deprecated here, and nothing that stopped compiling. What there is, is a consequence that was measured rather than assumed:

  • Cipher.getInstance("AES") is AES/ECB. The same plaintext gives the same cipher text every time, so a file discloses which of its secrets are equal — the staging password and the production one, the two services sharing a key.
  • There is no integrity check. An edited value decrypts to something else instead of failing.
  • The passphrase is used as the raw key, which is why it had to be exactly 16, 24 or 32 characters long. That is not a key derivation; it is a length requirement.

What to do: encrypt the same values again with the tool above and replace them with markers. The passphrase can be the same one, and it no longer has to be padded to fit. The @EncryptedValue annotations then come off, because a marker names what decrypts it.

Decryptor and Encryptor remain part of the API, for anybody who wrote a real implementation against them. It is StandardEncryptor — an example, published for copying, that should not have been — that is gone from this page.

Project maintained by Matteo Baccan, and the awesome contributors.

Developed with IntelliJ IDEA

Hosted on GitHub