A command-line tool for encrypting sensitive values in YAML and JSON configuration files using age public-key cryptography.
confcrypt only encrypts values matching configured patterns while keeping the file structure readable. Supports multiple recipients with age keys, SSH keys, FIDO2 compatible devices or YubiKey OTP. Works similarly to sops, but more straightforward.
Features
- Selective encryption: Only encrypts values matching your defined key patterns
- Multiple recipients: Encrypt for multiple team members using age or SSH public keys
- SSH key support: Use existing SSH keys (ed25519, RSA) alongside native age keys
- FIDO2 support: Derive keys from FIDO2 hmac-secret extension (requires CGO build)
- YubiKey support: Derive keys from YubiKey HMAC challenge-response (OTP slot)
- Format preservation: Maintains YAML/JSON structure and comments
- Flexible key matching: Exact names, regex patterns, or JSON paths
- Idempotent: Re-running encryption leaves already-encrypted values unchanged
- CI-friendly:
checkcommand returns exit code 1 if unencrypted secrets found (also usable as pre-commit hook) - Key rotation:
rekeycommand to rotate the encryption key
Quick Start
Install confcrypt, then:
1. Have a keypair ready
You can use any of:
- Native age key:
age-keygen -o ~/.config/age/key.txt - Existing SSH key:
~/.ssh/id_ed25519(ed25519 or RSA) - FIDO2 device (YubiKey, FIDO2 compatible): Use hmac-secret extension (see FIDO2 Support)
- YubiKey OTP: Configure HMAC challenge-response (see YubiKey Support)
2. Initialize confcrypt
This creates a .confcrypt.yml with:
- Your public key as the first recipient (auto-detected from age key or SSH key)
- Default file patterns:
*.yml,*.yaml,*.json - Default sensitive key patterns:
/password$/,/api_key$/,/secret$/,/token$/
You can also specify a particular key file or hardware key:
# Use specific age key confcrypt init --age-key ~/.age/key.txt # Use specific SSH public key confcrypt init --ssh-key ~/.ssh/id_ed25519.pub # Use FIDO2 hmac-secret (requires CGO build) confcrypt init --fido2-key # Use YubiKey HMAC challenge-response confcrypt init --yubikey-key
3. Encrypt your config files
4. Decrypt when needed
# Decrypt a single file confcrypt decrypt config.yml # Decrypt all matching files confcrypt decrypt
Installation
Quick Install (recommended)
Download and install the latest release automatically:
curl -fsSL https://raw.githubusercontent.com/maurice2k/confcrypt/main/install.sh | shThis installs to /usr/local/bin by default. To install elsewhere:
INSTALL_DIR=~/.local/bin curl -fsSL https://raw.githubusercontent.com/maurice2k/confcrypt/main/install.sh | sh
From source (go install)
go install github.com/maurice2k/confcrypt@latest
Note: This builds without CGO, so FIDO2 support is disabled.
Build from source using Makefile
The project includes a Makefile with convenient build targets:
git clone https://github.com/maurice2k/confcrypt.git cd confcrypt # Build with CGO (FIDO2 support, requires libfido2) make build # Install to $GOPATH/bin (with CGO) make install # Build without CGO (no FIDO2 support, but portable) make build-nocgo # Install to $GOPATH/bin (without CGO) make install-nocgo # Cross-compile for all platforms (without CGO) make build-all-nocgo # Run tests make test # See all available targets make help
The Makefile automatically detects macOS with Homebrew and sets the correct CGO flags for libfido2.
Build from source with CGO (FIDO2 support)
For full FIDO2 hmac-secret support, you need to build with CGO enabled and libfido2 installed.
1. Install libfido2:
# macOS brew install libfido2 # Debian/Ubuntu sudo apt install libfido2-dev # Fedora sudo dnf install libfido2-devel
2. Build with CGO:
Using the Makefile (recommended):
Or manually on Linux:
CGO_ENABLED=1 go build -o confcrypt .On macOS without the Makefile, you may need to specify library paths:
# macOS (Apple Silicon) CGO_LDFLAGS="-L/opt/homebrew/opt/libfido2/lib -lfido2 -L/opt/homebrew/opt/openssl@3/lib -lcrypto" \ CGO_CFLAGS="-I/opt/homebrew/opt/libfido2/include -I/opt/homebrew/opt/openssl@3/include" \ CGO_ENABLED=1 go build -o confcrypt . # macOS (Intel) CGO_LDFLAGS="-L/usr/local/opt/libfido2/lib -lfido2 -L/usr/local/opt/openssl@3/lib -lcrypto" \ CGO_CFLAGS="-I/usr/local/opt/libfido2/include -I/usr/local/opt/openssl@3/include" \ CGO_ENABLED=1 go build -o confcrypt .
These flags tell the C compiler where to find the libfido2 headers (CGO_CFLAGS) and the linker where to find the libraries (CGO_LDFLAGS).
Manual Configuration
You can also create .confcrypt.yml manually:
# Recipients who can decrypt the files # Supports age keys (age:), SSH keys (ssh:), FIDO2 (fido2:), and YubiKey (yubikey:) recipients: - name: "Alice" age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p - name: "Bob" ssh: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... bob@example.com - name: "Carol" ssh: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAB... carol@example.com - name: "Dave" fido2: age1fido21qpzry9x8... # FIDO2-derived key - name: "Eve" yubikey: age1yubikey1q94ldgcz... # YubiKey-derived key # Files to process (glob patterns) files: - "*.yml" - "*.yaml" - "*.json" # Optional: Rename files during encrypt/decrypt rename_files: encrypt: - /(\.\w+)$/.enc\1/ # config.yml -> config.enc.yml decrypt: - /\.enc(\.\w+)$/\1/ # config.enc.yml -> config.yml # Keys to encrypt (exact match, /regex/, or $path) # Regex patterns are case-insensitive by default keys_include: - /password$/ - /api_key$/ - /secret$/ - /token$/ # Keys to exclude from encryption keys_exclude: - /_unencrypted$/
Usage
confcrypt [command] [options]
Commands:
init Initialize a new .confcrypt.yml config file
encrypt Encrypt matching keys (default)
decrypt Decrypt encrypted values
check Check for unencrypted keys (exit 1 if found)
rekey Rotate the AES-256 key and re-encrypt all values
recipient add Add a recipient (public key required, --name optional)
recipient rm Remove a recipient by public key (rekeys by default)
Global Options:
--path string Base path where .confcrypt.yml is located (default: current directory)
--config string Path to .confcrypt.yml config file (overrides --path)
--file string Process a specific file only
--stdout Output to stdout instead of modifying files in-place
--version Show version
--help Show help
Encrypt Options:
--dry-run Show what would be encrypted without making changes
--json Output encrypted fields in JSON format
Decrypt Options:
--output-path string Write decrypted files to this directory
--output-tar string Write decrypted files to tar archive (use '-' for stdout)
--force Continue decryption even if MAC verification fails
Examples
Encrypting a config file
Before (config.yml):
database: host: localhost port: 5432 username: admin password: supersecret api: endpoint: https://api.example.com api_key: sk_live_12345
After running confcrypt:
database: host: localhost port: 5432 username: admin password: ENC[AES256_GCM,data:c3VwZXJzZWNyZXQ=,iv:...,tag:...,type:str] api: endpoint: https://api.example.com api_key: ENC[AES256_GCM,data:c2tfbGl2ZV8xMjM0NQ==,iv:...,tag:...,type:str]
Key Matching Patterns
| Pattern | Type | Description |
|---|---|---|
password |
Exact | Matches any key named "password" at any depth |
/^api_/ |
Regex | Matches keys starting with "api_" (case-insensitive) |
/_secret$/ |
Regex | Matches keys ending with "_secret" (case-insensitive) |
$db.password |
Path (relative) | Matches "password" inside any "db" object |
$.db.password |
Path (absolute) | Matches "password" in root-level "db" only |
Regex Case Sensitivity
Regex patterns are case-insensitive by default. This means /password$/ will match password, PASSWORD, Password, etc.
To make a regex case-sensitive, use the object form with options: -i:
keys_include: # Case-insensitive (default) - matches "api_key", "API_KEY", "Api_Key" - /api_key$/ # Case-sensitive - only matches exactly "api_key" - key: /api_key$/ type: regex options: "-i"
Explicit type for edge cases
If your key name starts with $ or /, use the object form:
keys_include: - key: "$special_var" type: exact - key: "/literal/slashes/" type: exact
Preview encryption (dry-run)
# Show what would be encrypted (human-readable) confcrypt encrypt --dry-run # Show what would be encrypted (JSON format) confcrypt encrypt --dry-run --json
JSON output
# Encrypt and output what was encrypted in JSON format
confcrypt encrypt --jsonOutput format:
{
"files": {
"config.yml": ["database.password", "api.api_key"],
"secrets.json": ["credentials.token"]
}
}Check for unencrypted secrets (CI usage)
confcrypt check # Exit code 0: All matching keys are encrypted # Exit code 1: Found unencrypted keys
Decrypt to stdout
confcrypt decrypt --stdout config.yml
Decrypt to a different directory
# Decrypt to a separate directory (preserves encrypted source files) confcrypt decrypt --output-path ./decrypted/ # Use absolute path confcrypt decrypt --output-path /tmp/decrypted-configs/
When using --output-path:
- Decrypted files are written to the specified directory, preserving the relative path structure
- Source files remain encrypted
Decrypt to a tar archive
# Decrypt to a tar file confcrypt decrypt --output-tar decrypted.tar # Stream directly to stdout (e.g., pipe to another process) confcrypt decrypt --output-tar - | tar -xf - -C /target/dir
When using --output-tar:
- Decrypted files are written to a tar archive with the base directory as prefix (e.g.,
myproject/config.yml) - Source files remain encrypted
- Use
-to stream the tar to stdout (info messages go to stderr)
Automatic file renaming
You can configure confcrypt to rename files during encryption/decryption using regex patterns:
rename_files: encrypt: - /(\.\w+)$/.enc\1/ # config.yml -> config.enc.yml decrypt: - /\.enc(\.\w+)$/\1/ # config.enc.yml -> config.yml
The pattern format is /regex/replacement/ where:
regexis matched against the filename (not the full path)replacementcan use\1,\2, etc. for capture groups- Multiple patterns are checked in order; processing stops at the first match
- If no pattern matches, the filename remains unchanged
Example workflow:
# Encrypt: config.yml -> config.enc.yml (original deleted) confcrypt encrypt # Decrypt: config.enc.yml -> config.yml (encrypted deleted) confcrypt decrypt # Decrypt to output dir: config.enc.yml stays, config.yml created in output dir confcrypt decrypt --output-path ./decrypted/
Managing Recipients
confcrypt supports adding and removing recipients dynamically.
Add a recipient (recipient add)
# Add with age key confcrypt recipient add --name "Bob" age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg # Add with SSH key confcrypt recipient add --name "Carol" "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... carol@example.com" # Add without name confcrypt recipient add age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg
What happens:
- The new recipient is added to the
recipientslist in.confcrypt.yml - If encrypted secrets exist, the existing AES-256 key is encrypted for the new recipient
- The new recipient can now decrypt all config files with their private key
Note: No rekeying occurs - the same AES-256 key is used, just encrypted for an additional recipient.
Remove a recipient (recipient rm)
# Default: rekeys (generates new AES-256 key, re-encrypts everything) confcrypt recipient rm age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg # Skip rekeying (just remove their access to current key) confcrypt recipient rm --no-rekey age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg
Default behavior (with rekey):
- The recipient is removed from the
recipientslist - A new AES-256 key is generated
- All encrypted values are decrypted and re-encrypted with the new key
- The new key is encrypted for remaining recipients only
- The removed recipient cannot decrypt any files (even if they had a copy of the old AES-256 key)
With --no-rekey:
- The recipient is removed from the
recipientslist - The existing AES-256 key is re-encrypted for remaining recipients only
- The removed recipient loses access, but if they had a copy of the old AES-256 key, they could still decrypt
Note: You cannot remove the last recipient - at least one must remain.
Key Rotation (rekey)
Rotate the AES encryption key and re-encrypt all values:
What happens:
- All encrypted values are decrypted using the current AES-256 key
- A new random AES-256 key is generated
- All values are re-encrypted with the new key
- The new key is encrypted for all current recipients
- MACs are updated for all files
Use cases:
- Regular key rotation policy
- After a security incident
- After removing a recipient (done automatically by default)
Private Key Configuration
confcrypt looks for your private key in this order (age keys take precedence over SSH keys):
SOPS_AGE_KEY_FILEenvironment variable (for sops compatibility)CONFCRYPT_AGE_KEY_FILEenvironment variableCONFCRYPT_AGE_KEYenvironment variable (key content directly)CONFCRYPT_SSH_KEY_FILEenvironment variable (SSH private key file)~/.config/age/key.txt(default age location)~/.ssh/id_ed25519(SSH ed25519 key)~/.ssh/id_rsa(SSH RSA key)- FIDO2 recipients from
.confcrypt.yml(auto-detected, requires touch/PIN) - YubiKey recipients from
.confcrypt.yml(auto-detected, requires touch)
Supported Key Types
| Key Type | Recipient (encryption) | Identity (decryption) |
|---|---|---|
| Native age (X25519) | age: field |
age key file |
| SSH ed25519 | ssh: field |
~/.ssh/id_ed25519 |
| SSH RSA | ssh: field |
~/.ssh/id_rsa |
| FIDO2 hmac-secret | fido2: field |
FIDO2 device |
| YubiKey HMAC | yubikey: field |
YubiKey device |
| SSH sk-ed25519 (FIDO) | Not supported | Not supported |
Note: SSH sk-ed25519 (hardware-backed FIDO keys) are not supported because the private key material cannot be exported from the hardware token. Use the FIDO2 hmac-secret support instead.
FIDO2 Support
confcrypt can derive encryption keys from FIDO2 devices using the hmac-secret extension. This provides stronger crypto (SHA-256) and optional PIN protection.
Note: FIDO2 support requires building confcrypt with CGO enabled and libfido2 installed. See Build from source with CGO in the Installation section.
Generate a FIDO2 recipient
confcrypt keygen fido2
confcrypt keygen fido2 --pin # Require PINThis outputs a recipient string like:
age1fido21qpzry9x8gf2tvdw0s3jn54khce6mua7l...
Add FIDO2 recipient to project
confcrypt recipient add --name "Your Name" age1fido21qpzry9x8...How it works
- Credential creation: A FIDO2 credential is created with the hmac-secret extension
- Salt generation: A random 32-byte salt is generated
- Secret derivation: The device computes HMAC-SHA256 using its internal secret and the salt
- Key derivation: The secret is used to derive an X25519 keypair
- Decryption: Touch the device (and enter PIN if configured) to re-derive the private key
FIDO2 vs YubiKey OTP
| Feature | FIDO2 hmac-secret | YubiKey OTP HMAC |
|---|---|---|
| Algorithm | HMAC-SHA256 | HMAC-SHA1 |
| PIN support | Yes | No |
| External tool | libfido2 (CGO) |
ykman |
| Build | Requires CGO | Standard Go |
YubiKey Support
confcrypt supports deriving encryption keys from YubiKey HMAC challenge-response. This provides hardware-backed key derivation without storing any secrets on disk.
Prerequisites
-
Install
ykman(YubiKey Manager):brew install ykman # macOS pip install yubikey-manager # or via pip
-
Configure HMAC challenge-response on your YubiKey:
ykman otp chalresp --generate 2 --touch
This configures slot 2 with a random secret and requires touch for each operation.
Generate a YubiKey recipient
This outputs a recipient string like:
age1yubikey1q94ldgcz5v2ejqt7gt7vrxxg6jr652pe8guse6kgnctrc9x3hev52wwr8588z7a3ukc7ewwy72ssts0xm0r5xy9yk6jjjrlzz7thuta9wcve2ygv44r0y
The recipient string contains:
- YubiKey serial number (for device identification)
- HMAC slot (1 or 2)
- Random challenge (salt)
- X25519 public key
Add YubiKey recipient to project
confcrypt recipient add --name "Your Name" age1yubikey1q94ldgcz...How it works
- Key generation: A random 32-byte challenge is generated and sent to the YubiKey
- HMAC response: The YubiKey computes HMAC-SHA1 using its internal secret
- Key derivation: The response is combined with the challenge via SHA256 to derive an X25519 keypair
- Encryption: The derived public key is used for age encryption
- Decryption: Touch the YubiKey to re-derive the private key on-demand
The private key is never stored - it's derived each time using the YubiKey.
Encrypted Value Format
Values are encrypted using AES-256-GCM and stored in this format:
ENC[AES256_GCM,data:<base64>,iv:<base64>,tag:<base64>,type:<type>]
data: AES-GCM ciphertext (base64)iv: 12-byte initialization vector (base64)tag: 16-byte authentication tag (base64)type: Original value type (str,int,float,bool,null)
The AES-256 key is randomly generated per config and encrypted for each recipient using their public key (age or SSH).
Config File Structure
After encryption, confcrypt adds a .confcrypt section to your .confcrypt.yml:
.confcrypt: version: "1.0" updated_at: "2026-01-16T12:00:00Z" store: - recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p secret: !!binary | YWdlLWVuY3J5cHRpb24... macs: config.yml: ENC[AES256_GCM,data:...,iv:...,tag:...,type:bytes] config.json: ENC[AES256_GCM,data:...,iv:...,tag:...,type:bytes]
version: Config format versionupdated_at: Last encryption timestamp (UTC)store: AES-256 key encrypted for each recipientmacs: Per-file Message Authentication Codes (SHA256 hash of encrypted values, encrypted)
Tamper Detection
confcrypt computes a MAC (Message Authentication Code) for each encrypted file. The MAC is a SHA256 hash of all encrypted values, which is then encrypted with the same AES-256 key.
On decryption, confcrypt verifies the MAC before decrypting. If the encrypted values have been tampered with, decryption fails:
Error: config.yml: MAC verification failed - file may have been tampered with
Use --force to decrypt anyway
To proceed despite tampering detection:
confcrypt decrypt --force
This protects against:
- Modification of encrypted ciphertext
- Swapping encrypted values between fields
How It Works
confcrypt uses a two-layer encryption scheme:
Layer 1: AES-256-GCM encryption
The AES-256-GCM encryption is used to encrypt the values that require encryption according to the rules in the config file.
┌─────────────────────────────────────────────────────────────────┐
│ Config values that should be encrypted │
│ api_key: "sk_live_..." │
│ password: "secret123" │
└─────────────────────────────────────────────────────────────────┘
│
| generate or reuse an AES-256 key ("secret") and
| and encrypt the values with it using AES-256-GCM
▼
┌─────────────────────────────────────────────────────────────────┐
│ Encrypted values │
│ api_key: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str] │
│ password: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str] │
└─────────────────────────────────────────────────────────────────┘
Layer 2: Public-key encryption
The public-key encryption (age or SSH) is used to encrypt the AES-256 key ("secret") for each recipient using their public key.
┌─────────────────────────────────────────────────────────────────┐
│ AES-256 key ("secret") │
└─────────────────────────────────────────────────────────────────┘
│
| encrypt the secret with each recipient's public key
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Alice's │ │ Bob's │ │ Carol's │
│ pub key │ │ pub key │ │ pub key │
└──────────┘ └──────────┘ └──────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Secret │ │ Secret │ │ Secret │
│encrypted │ │encrypted │ │encrypted │
│for Alice │ │ for Bob │ │for Carol │
└──────────┘ └──────────┘ └──────────┘
Encryption Flow
- Generate a random AES-256 key ("secret") or reuse existing one
- Encrypt each matching value with AES-256-GCM (produces ciphertext + IV + auth tag)
- Encrypt the AES-256 key ("secret") separately for each recipient using their public key
- Store encrypted AES-256 keys in
.confcrypt.storeinside the .confcrypt.yml file
Decryption Flow
- Use your private key to decrypt your copy of the AES-256 key ("secret")
- Use the AES-256 key ("secret") from step 1 to decrypt all encrypted values
Why This Design?
This approach allows multiple recipients to both decrypt AND encrypt the same files without sharing a master secret in plaintext:
- Any recipient can decrypt existing secrets (they have the AES-256 key)
- Any recipient can encrypt new secrets (same AES-256 key)
- Adding a recipient only requires encrypting the AES-256 key for them (no re-encryption of values)
- Removing a recipient with
rekeygenerates a new AES-256 key they don't have access to
License
MIT License - see LICENSE file.