Misk Crypto & Secrets: Keys, Encryption, and Config Secrets Done Safely
Series: Building Production Services with Misk — Part 11 of 24
Every service eventually needs to encrypt something — a payment token, a PII column, a webhook payload — and every service eventually needs a secret it can’t check into git. Both problems are easy to get catastrophically wrong: a hand-rolled Cipher.getInstance("AES") with a hard-coded key, an API token pasted into a YAML file that ends up in a log line. Misk crypto and misk secrets are the two modules that exist so you stop doing that. The first hands you Google Tink primitives backed by a real KMS; the second loads credentials by reference so the actual bytes never live in your config or your dashboards. Neither asks you to understand the underlying cryptography, which is exactly the point.
misk crypto: Tink primitives, keyed by name
The misk-crypto module’s job is small and sharp: read a configured list of keys, decrypt each one against a KMS, and bind the resulting Tink primitive into Guice under a @Named qualifier. You never touch key material. You inject an Aead, a Mac, a StreamingAead — the interfaces Tink already defines — and Misk has already done the unwrapping.
The supported primitives map directly to a KeyType enum in the config:
enum class KeyType {
AEAD, // authenticated encryption -> com.google.crypto.tink.Aead
DAEAD, // deterministic AEAD -> DeterministicAead
MAC, // message authentication -> Mac
DIGITAL_SIGNATURE, // sign/verify -> PublicKeySign / PublicKeyVerify
HYBRID_ENCRYPT, // public-key encrypt -> HybridEncrypt
HYBRID_ENCRYPT_DECRYPT, // encrypt + decrypt
STREAMING_AEAD, // large files / streams -> StreamingAead
PGP_DECRYPT, PGP_ENCRYPT, // PgpDecrypter / PgpEncrypter
SIGNATURE, // alias for DIGITAL_SIGNATURE
}
That’s the whole vocabulary. A key of type AEAD becomes an injectable Aead; a MAC becomes a Mac. The module registers every Tink config (AeadConfig.register(), MacConfig.register(), and friends) on startup, so you don’t have to remember to initialize Tink yourself — a step people forget exactly once before shipping a NoSuchAlgorithmException to prod.
The detail worth internalizing: the Aead you get is an envelope Aead. Data is encrypted with an ephemeral data-encryption key (DEK), and the DEK is wrapped by the key-encryption key (KEK) and stored inline with the ciphertext. Two consequences fall out of that — ciphertext is a little larger than plaintext, and rotating the KEK does not require re-encrypting your stored data. For anyone who has lived through a “we changed the key, now decrypt 40 million rows” migration, that second property is the whole reason to use this.
Setup / wiring
Two modules, one config block. First, install a KMS client module so Tink can reach your key store, then CryptoModule with the parsed config:
class MyServiceCryptoModule(private val config: CryptoConfig) : KAbstractModule() {
override fun configure() {
install(AwsKmsClientModule()) // or GcpKmsClientModule()
install(CryptoModule(config))
}
}
AwsKmsClientModule and GcpKmsClientModule each take an optional credentials-file path; with no argument they fall back to Tink’s default credentials chain (AwsKmsClient().withDefaultCredentials()). Both provide a single KmsClient binding — and CryptoModule opens with requireBinding<KmsClient>(), so if you forget the KMS module the injector fails fast at startup rather than at the first encrypt() call.
The config is a CryptoConfig:
data class CryptoConfig(
val keys: List<Key>?,
val kms_uri: String, // default KMS key URI
val external_data_keys: Map<KeyAlias, KeyType>? = null,
)
data class Key(
val key_name: String,
val key_type: KeyType,
val encrypted_key: Secret<String>? = null, // Tink JSON, loaded by reference
val kms_uri: String? = null, // optional per-key override
)
And the matching YAML — note that encrypted_key is a Secret<String>, so you reference the keyset file, you don’t paste it:
crypto:
kms_uri: "aws-kms://arn:aws:kms:us-east-1:<account-id>:key/<key-id>"
keys:
- key_name: "payment_token_key"
key_type: AEAD
encrypted_key: "classpath:/crypto/payment_token_key.json"
You generate that keyset file with Tink’s tinkey tool, encrypting it under the same KMS your service uses:
tinkey create-keyset --key-template AES256_GCM \
--master-key-uri aws-kms://arn:aws:kms:<region>:<account-id>:key/<key-id> \
--out payment_token_key.json
Gradle coordinates, against the com.squareup.misk group:
dependencies {
implementation("com.squareup.misk:misk-crypto")
implementation("com.squareup.misk:misk-config")
}
Worked example
Here’s a token cipher, lifted from the module README and trimmed. Inject the Aead by the name you gave it in config:
@Singleton
class PaymentTokenCipher @Inject constructor(
@Named("payment_token_key") private val tokenCipher: Aead,
) {
fun encrypt(token: String): ByteArray =
tokenCipher.encrypt(token.toByteArray(), null)
fun decrypt(ciphertext: ByteArray): String =
String(tokenCipher.decrypt(ciphertext, null), Charsets.UTF_8)
}
The second argument to encrypt/decrypt is Tink’s associated data — authenticated but not encrypted context (a user ID, a row key) that must match on decrypt or the operation fails. Use it; it’s free integrity binding.
If you need a key chosen at runtime rather than at inject time, every primitive has a manager that resolves by name. AeadKeyManager is the Aead flavour:
@Singleton
class DynamicEncryptor @Inject constructor(
private val aeadKeyManager: AeadKeyManager,
) {
fun encrypt(keyName: String, plaintext: ByteArray): ByteArray =
aeadKeyManager[keyName].encrypt(plaintext, null)
}
There’s a parallel manager per primitive — MacKeyManager, DeterministicAeadKeyManager, StreamingAeadKeyManager, DigitalSignatureKeyManager (which exposes getSigner(name) / getVerifier(name)), and the PGP pair. Reach for the manager only when the key name is genuinely dynamic; otherwise @Named injection is clearer and fails earlier.
misk secrets: references, not values
Now the other half. The exemplar’s config class is the canonical example of how Misk wants you to handle credentials:
data class ExemplarConfig(
val apiKey: Secret<String>,
val web: WebConfig,
val prometheus: PrometheusConfig,
@Redact val redacted: String,
// ...
) : Config
A field typed Secret<T> is never inlined in YAML. Instead the YAML holds a resource reference, and Misk resolves it at load time:
apiKey: "classpath:/secrets/api_key.txt"
The SecretDeserializer reads that string, loads the resource through Misk’s ResourceLoader (classpath:, filesystem:, and other schemes), and wraps the result in a RealSecret. You read the value through .value:
class ApiClient @Inject constructor(config: ExemplarConfig) {
private val token = config.apiKey.value // the actual secret, loaded from the .txt
}
The shape of Secret<T> is deliberately minimal — one property:
interface Secret<T> {
val value: T
}
A .txt reference deserializes to Secret<String>; a .yaml reference deserializes to a typed Secret<SomeConfig>, so you can keep a whole block of structured-but-sensitive config in a separate file and inject it as one object. The credential bytes live wherever your deploy puts that file (a mounted secret volume, a classpath entry baked at deploy time) — never in the config that’s committed to your repo.
Going deeper: why this keeps secrets out of logs and dashboards
The interesting design choice isn’t the loading — it’s what happens when something tries to print a secret. Two layers protect you.
First, RealSecret overrides toString() to censor itself:
override fun toString(): String = "RealSecret(value=████████, reference=$reference)"
So the most common leak — a debug log of config, an exception that interpolates a field — shows ████████, never the value. You’d have to explicitly call .value to leak it, which makes the leak intentional and greppable.
Second, Misk renders config to its admin dashboard, and that path goes through toRedactedYaml, which installs a RedactSecretJacksonModule. Every Secret<*> serializes as its reference plus a censored value (classpath:/secrets/api_key.txt -> ████████), so the dashboard tells you which secret a field points at without exposing the secret itself. That’s genuinely useful operationally — you can confirm “yes, prod is pointed at the prod token file” without the value ever crossing the wire.
The @Redact annotation is the escape hatch for the rest. Plenty of sensitive config isn’t a Secret<T> — a plain String from a framework type, a nested config block. Annotate the field (or the whole data class) with @Redact and the same dashboard serializer writes ████████ in its place. It’s a Jackson serializer under the hood (@JacksonAnnotationsInside + @JsonSerialize), so it only affects the redacted-output path, not your in-memory value.
The honest framing: Secret<T> protects the value at rest in config and in stringification; @Redact protects arbitrary fields in dashboard output. They overlap but aren’t the same tool. Use Secret<T> when the value should be loaded by reference; use @Redact when a value lives inline but shouldn’t be shown.
Production notes & gotchas
-
A local key with no
encrypted_keyis a hard startup failure.CryptoModulerunskeys.map { it.encrypted_key }.requireNoNulls()and throws “Found local key with no ‘encrypted_key’ value.” There’s no silent fallback to an unencrypted key — and there shouldn’t be. -
Key names share one namespace across providers, and duplicates fail the build. The module checks for duplicate
key_names andchecks that nothing appears both as a local key and anexternal_data_keysentry. This is intentional friction: it stops you from quietly shadowing one key store with another during a migration. -
DAEADleaks equality by design. Deterministic AEAD lets you query by ciphertext (same plaintext, same bytes — soWHERE encrypted_col = ?works), but that’s exactly why an attacker can detect repeated plaintexts. TheDeterministicAeadKeyManagerdocs say this out loud. Reach forDAEADonly when you actually need searchability, not as a default. -
The
ByteStringextension functions are deprecated toHIDDEN. Older Misk code usedAead.encrypt(ByteString); those extensions are now@Deprecated(level = HIDDEN)and tell you to use Tink’s rawencrypt(ByteArray, ByteArray). Don’t reintroduce them — go straight to the Tink interface. -
Hybrid encryption is split into two key managers on purpose.
HybridEncryptKeyManagerandHybridDecryptKeyManagerare separate so the public (encrypt-only) half of a keyset can be exported to other services while the private half stays put. If you find yourself wanting “just give me both,” reconsider — the split is the security property. -
.valueis the leak line.toString()and the dashboard are covered, but the moment you callsecret.valuethe raw bytes are yours, and any log statement you write with them is on you. Treat.valueas the boundary where Misk’s protection ends.
What’s next
Crypto and secrets handle the data you protect; next we deal with the data you store. In Part 12: Misk JDBC & Data Sources we’ll wire connection pooling, configure data-source clusters, and meet the Transacter at the raw JDBC layer — including, naturally, where its database password should live (hint: it’s a Secret).
Target keywords: misk crypto, misk secrets.
Comments