Cryptor and EncryptedProperties — Application-Specific Secret Handling¶
Overview and Motivation¶
Every real deployment has the same awkward problem: the application needs a database password, a
keystore passphrase, a daemon credential — and those have to live somewhere the application can
read them at startup. A plain-text backend.properties in a distribution image is the outcome
nobody wants but many projects end up with.
Tentackle's answer is deliberately modest, and its own javadoc is candid about why:
The security of symmetric encryption algorithms in general depends on the confidentiality of the passphrase. Thus, the passphrase should ideally not be part of the application, but provided via some external media, a mounted USB-stick, manual input, PGP keyring, whatever. However, in practice this isn't always doable.
So Cryptor is not a secrets-management system and
does not pretend to be one. It is a symmetric en/decryptor that an application subclasses with its
own salt and passphrase, and that the framework then uses consistently everywhere a secret would
otherwise sit in the clear:
- at build time — the tentackle-maven-plugin encrypts passwords into filtered resources, so the packaged image contains no clear-text secrets;
- in configuration —
EncryptedPropertiestransparently decrypts any value marked with a~; - in memory —
DefaultSessionInfoholds the user's password encrypted rather than as a barechar[]; - on the wire — the TRIP
tripe/tripcetransports encrypt with the sameCryptor(see io.md).
What it buys you is honest and worth stating plainly: it raises the cost of a casual compromise — someone reading a properties file out of a distribution image, a support archive, or a log — and it keeps secrets from being incidentally disclosed. It does not defend against an attacker who can read your application's own bytes, because such an attacker can recover the passphrase from them. Judge it on that basis, and put genuinely high-value secrets behind an externally supplied passphrase (see Threat model below).
Cryptor¶
Providing one¶
Cryptor is optional. Without one, the framework stores secrets unencrypted and everything still
works. An application enables it by subclassing and registering with the
service API:
@Service(Cryptor.class)
public class MyAppCryptor extends Cryptor {
public MyAppCryptor() {
super(getSalt(), getPassphrase());
}
...
}
Two lookups exist, and the difference matters:
| Method | Behavior |
|---|---|
Cryptor.getInstance() |
The singleton, or null if the application configured none. Callers must handle null — this is the "encryption is optional" path. |
Cryptor.getInstanceSafely() |
The singleton, throwing TentackleRuntimeException if absent. Used where a value is already known to be encrypted and there is no sane fallback. |
What it does¶
The (salt, passphrase) constructor derives a key with PBKDF2WithHmacSHA1 and encrypts with
AES, defaulting to 1024 iterations and a key strength of 256. A notable detail: the constructor
scratches its inputs — the passphrase char array is blanked and the salt array zeroed once the
key is derived, so they do not linger in the heap for a dump to find. The same care runs through the
API: encrypt(char[]) clears the array it was given, and decrypt64ToChars returns a char[]
precisely so the caller can erase it after use rather than being handed an immortal String.
The three algorithm choices are protected methods, so a subclass can override them:
| Method | Default |
|---|---|
getSecretKeyFactory() |
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") |
createSecretKeySpec(...) |
new SecretKeySpec(key, "AES") |
getCipher() |
Cipher.getInstance("AES") |
Note on the defaults.
Cipher.getInstance("AES")resolves, per the JCE provider defaults, to AES in ECB mode — which encrypts identical plaintext blocks to identical ciphertext blocks and uses no per-value IV. For short, high-entropy secrets like passwords this is mostly a non-issue; for anything longer or more structured it leaks patterns. Likewise 1024 PBKDF2 iterations is modest by current guidance. Both are adjustable without touching the rest of the framework: pass a higher iteration count to the(salt, passphrase, iterations, keyStrength)constructor, and overridegetCipher()to select an authenticated mode. If you do change the cipher, remember that build-time encryption and runtime decryption must use the sameCryptor— which they will, since both resolve it through the service API.
The API surface¶
Everything comes in byte, char and base64 flavors, because the right representation differs by caller:
encrypt(byte[])/decrypt(byte[]), with offset+length variants;encrypt(char[])— encodes, encrypts, and wipes both the char array and the intermediate bytes;encrypt64(...)/decrypt64(...)— base64 strings, the form used in properties files;decrypt64ToChars(...)/decryptToChars(...)— the erasable-result variants.
Cryptor also implements Function<String,String> (apply = encrypt64). That is not an
idle convenience: it is exactly what lets the Maven plugin use an application's own cryptor as a
property converter, described below.
deriveURL — the hidden gem¶
deriveURL(url, protocols...) decrypts a URL that has
been disguised behind a protocol the application doesn't actually use. If the URL starts with one of
the given fake protocols, the first token after =~ is decrypted and returned:
The javadoc labels it "Hidden gem... ;)" — it lets a real endpoint travel inside something that looks like an ordinary web link.
EncryptedProperties¶
EncryptedProperties is a java.util.Properties
subclass with one central idea: a value beginning with ~ is encrypted, and getProperty
returns it decrypted. Nothing else in the application needs to know.
url=jdbc:postgresql://dbhost/mydb
user=myapp
password=~GK+AG1QIjpBaD51HP/kw9HzpdKZLt2FrInFxd1jtPWvGzaw5lcLcHy5RB
getProperty("password") hands back the clear-text password; the file never contains it. If a
genuine value happens to start with a tilde, escape it with a backslash (\~notEncrypted).
Reading values¶
| Method | Returns |
|---|---|
getProperty(key) |
Decrypted value (via Cryptor.getInstanceSafely() when the value is marked ~) |
getPropertyAsChars(key) |
Same, as a char[] the caller can erase after use — prefer this for secrets |
getPropertyBlunt(key) |
The raw stored value, still encrypted. For copying/inspecting without decrypting |
getPropertyIgnoreCase(key) |
Case-insensitive lookup |
Note the asymmetry that follows from getInstanceSafely(): reading a ~-marked value without a
configured Cryptor throws rather than returning ciphertext. That is intentional — silently handing
an encrypted string to code expecting a password would fail later, and less clearly.
Writing values¶
setEncryptedProperty(key, value) accepts a String or a char[] and stores it encrypted with the
~ marker — if a Cryptor is configured. If none is, it stores the value as-is. This is the
"encryption is optional" principle again: the same code path works in both cases, and the file
format tells you which happened.
Key prefixes and case-insensitivity¶
Two features exist for integration rather than security:
setKeyPrefix(...)— a prefix prepended to keys ongetPropertyand friends, so Tentackle's settings can be merged into a shared file (a Springapplication.properties, say) without colliding.stringPropertyNames()then reports only the prefixed key space, with the prefix stripped. Caveat from the javadoc: the low-levelHashtablemethods that deal inObjects bypass the prefix entirely.getPropertyIgnoreCase/getKeyIgnoreCase— a lazily built case map. It throwsTentackleRuntimeExceptionif two keys collide case-insensitively, rather than silently picking one.
setName(...) labels the source (typically the file name) for diagnostics.
How It Fits Together¶
Build time: encrypting secrets into filtered resources¶
This is the part most projects actually rely on, and the archetype wires it up for you. The
tentackle-maven-plugin's properties goal converts a Maven property through a converter class and
publishes the result as a new property:
<propertyDescriptors>
<propertyDescriptor>
<input>${dbPasswd}</input>
<converter>@org.tentackle.common.Cryptor</converter>
<property>encryptedPasswd</property>
</propertyDescriptor>
</propertyDescriptors>
The @ prefix means "look the converter up via META-INF/services" — so it resolves to the
application's @Service(Cryptor.class), not the base class. (That is why the plugin declares the
application's -common artifact in its own <dependencies>: the converter must be on the plugin's
classpath.) Because Cryptor implements Function<String,String>, the plugin can call it directly.
The resulting property lands in a filtered resource with the ~ marker written by hand:
url=${dbUrl}?sslmode=disable
user=${dbUser}
password=~${encryptedPasswd}
^javax.net.ssl.keyStorePassword=~${encryptedCertPasswd}
The clear-text password lives only in the build environment (a developer's settings, a CI secret);
the packaged jlink image
contains only ciphertext. At runtime the session layer loads this file as EncryptedProperties and
getProperty("password") just works — see
session.md and the
MyApp walkthrough.
Runtime: the rest of the chain¶
BackendConfigurationstores backend passwords encrypted when aCryptoris configured.DefaultSessionInfokeeps the user's password asencryptedPasswordbytes rather than a clearchar[], so a heap dump of a running client doesn't surrender it.- Login and auto-update pass the already-encrypted credentials along on restart, which is how fx-rdc-update restarts a client seamlessly without re-prompting.
- TRIP transports
tripeandtripceencrypt the connection with the sameCryptoras a pre-shared key — see io.md for the streams and trip.md for the transport schemes.
One Cryptor therefore covers the secret's whole life: encrypted at build, decrypted at startup,
held encrypted in memory, and used as the PSK on the wire.
Threat Model and Limits¶
Being clear about this is more useful than overselling it.
What it protects against. Secrets sitting in plain text where they can be read without attacking the application: properties files inside distribution images, configuration checked into a repository, support bundles, backups, screenshots, heap dumps of a running client.
What it does not protect against. An attacker who can read your application's code or memory.
The passphrase is, by construction, available to the application — so it is available to anyone who
has the application. The archetype's generated MyAppCryptor illustrates the trade honestly: it
assembles its salt and passphrase character-by-character in scrambled order (salt[1]='E';
salt[0]='D'; …) with the comment "just to make it a little harder...". That is obfuscation, not
security, and the generated code says so.
The recommended step up, straight from the class javadoc: derive the passphrase from an
external source — manual entry at startup, a mounted volume, a PGP keyring, a platform secret
store — instead of compiling it in. The Cryptor constructor takes a char[]; where it comes from
is entirely up to your subclass, and everything downstream (EncryptedProperties, the transports,
SessionInfo) is unaffected by the change.
If you need more, use more: a real secrets manager, per-environment key rotation, an
authenticated cipher mode via getCipher(), a higher iteration count. Cryptor is designed to be
extended, and the service API means one class swap changes it everywhere.
Related Documentation¶
- Tentackle Common — the module these classes belong to.
- Service and Configuration API — how
@Service(Cryptor.class)is discovered. - Tentackle Maven Plugin — the
propertiesgoal and converters. - Tentackle Session — backend configuration and credentials.
- I/O and Networking — the encrypting streams built on
Cryptor. - TRIP — the
tripe/tripcetransports. - MyApp Walkthrough — a generated application's
Cryptorend to end. - Jlink/Jpackage Maven Plugin — the images the encrypted secrets ship in.