age-plugin-keystore
An age plugin that stores X25519 private
keys in Linux Keyrings using the Secret Service D-Bus API.
Overview
age-plugin-keystore integrates age encryption with Linux keyrings that uses
freedesktop Secret Service D-Bus API (like the GNOME Keyring), allowing
you to:
- Generate X25519 key pairs with private keys stored securely in keyring
- Encrypt files to keystore recipients
- Decrypt files using keys retrieved from the keyring automatically
Rationale
Ideally, encryption and decryption operations should be performed within a
secure enclave, an isolated hardware-protected environment that shields
cryptographic operations from the rest of the system. However, Linux user
keyrings provide a reasonable alternative for lighter security requirements,
offering kernel-managed key storage without the need for expensive hardware
security modules or physical tokens.
The keyring offers several advantages over storing keys directly on disk. Keys
stored in the keyring are tied to user authentication, they become accessible
only after login. Unlike plaintext files that persist indefinitely and may be
inadvertently copied through backups or synchronization, keyring-stored keys
exist only in kernel memory and never touch the filesystem in unencrypted form.
This protection model emphasizes defense against the remote threat scenario: an
attacker who gains access to your repository (through a server breach, backup
leak, or misconfigured permissions) will find only encrypted data they cannot
decrypt. The keys remain in the keyring on your local machine, separate from the
encrypted content.
It is important to understand the limitations of this approach. A malicious
process running under your user account on the same machine can potentially
access the keyring and decrypt your secrets. While a true hardware secure
enclave offers stronger protection against such local attacks, the added
complexity and cost make it impractical for many use cases. The Linux keyring
strikes a pragmatic balance as it defends well against remote threats and casual
local snooping, while accepting that a fully compromised local environment
remains difficult to protect against without specialized hardware and systems.
Prerequisites
- Go 1.22 or later
- GNOME Keyring or another Secret Service API implementation
Installation
go install github.com/arouene/age-plugin-keystore@latest
Or build from source:
git clone https://github.com/arouene/age-plugin-keystore
cd age-plugin-keystore
go build -o age-plugin-keystore .
Make sure the binary is in your PATH for age to find it automatically:
cp age-plugin-keystore ~/.local/bin/
# or
sudo cp age-plugin-keystore /usr/local/bin/
Usage
Generate a New Key
age-plugin-keystore -g
This will:
- Generate a new X25519 key pair
- Store the private key in GNOME Keyring
- Print the identity string (for identity files) and public key (recipient with embedded key ID)
Example output:
# created: key stored in GNOME Keyring
# key ID: a1b2c3d4e5f6g7h8
# public key: age1keystore1qp...
AGE-PLUGIN-KEYSTORE-1...
Generate a Key with Separate Identity
Use the -s or --separate-identity flag to generate a key pair where the
public key is a standard age public key (age1...) instead of a keystore
recipient (age1keystore1...):
age-plugin-keystore -g -s
# or
age-plugin-keystore -g --separate-identity
Example output:
# created: key stored in GNOME Keyring
# key ID: a1b2c3d4e5f6g7h8
# public key: age1...
AGE-PLUGIN-KEYSTORE-1...
When to use separate identity mode:
- When you want to share the public key without revealing you're using a keystore
- When recipients don't need to know about or have the plugin installed
- When you want a standard age public key that works with any age implementation
- When only the decryption side needs the plugin (encryption works with standard age)
Save the identity string to a file:
age-plugin-keystore -g > identity.txt
Encrypt a File
Use the public key (recipient) printed during key generation:
age -r age1keystore1qp... plaintext.txt > encrypted.age
# or
age -R recipients.txt plaintext.txt > encrypted.age
Decrypt a File
Use the identity file created during key generation:
age -d -i identity.txt encrypted.age > plaintext.txt
The plugin will automatically retrieve the private key from the secret service.
List Stored Keys
age-plugin-keystore -l
Delete a Key
age-plugin-keystore -d YOUR_KEY_ID
How It Works
Key Generation
- A new X25519 key pair is generated using
filippo.io/age
- A random 8-byte key ID is generated
- The private key (as an age identity string) is stored in Keyring with the key ID as an attribute
- The identity and recipient strings are output for use with age
Encryption
There are two modes of operation:
Standard mode (default): When encrypting to a keystore recipient (age1keystore1...),
the plugin generates a custom keystore stanza that includes the key ID. This allows
the identity to know exactly which key to retrieve from the keyring during decryption.
The encrypted file header will contain:
-> keystore <key-id> <ephemeral-share>
<wrapped-file-key>
Separate identity mode (-g -s): The public key is a standard age public key (age1...).
Encryption produces a standard X25519 stanza that any age implementation can create.
This mode allows encryption without requiring the plugin on the sender's side, but the
identity must try all keys in the keyring during decryption.
Decryption
When decrypting with a keystore identity (AGE-PLUGIN-KEYSTORE-1...):
- age invokes the plugin via the standard age plugin protocol
- The plugin extracts the key ID from the identity
- For
keystore stanzas: the plugin checks if the stanza's key ID matches and decrypts directly
- For
X25519 stanzas (separate identity mode): the plugin retrieves the private key and tries to decrypt
- The plugin returns the decrypted file key to age
Security Considerations
- Private Key Storage: Private keys are stored in GNOME Keyring, which encrypts them at rest
- Key Access: Keys are accessible to any process running as the same user when the keyring is unlocked
- Keyring Unlocking: The keyring is typically unlocked automatically when you log in
- No Passphrases: Unlike standard age keys, keystore keys are protected by the keyring's authentication, not individual passphrases
age1keystore1<bech32-encoded-data>
Where the encoded data contains: keyID:X25519-public-key
AGE-PLUGIN-KEYSTORE-1<bech32-encoded-key-id>
Standard mode produces a keystore stanza:
-> keystore <key-id> <base64-ephemeral-share>
<base64-wrapped-file-key>
Separate identity mode produces a standard X25519 stanza:
-> X25519 <base64-ephemeral-share>
<base64-wrapped-file-key>
Development
Building
go build -v ./...
Testing
go test -v ./...
Integration tests
go test -tags=integration -v ./test/
Project Structure
age-plugin-keystore/
├── main.go # Plugin entry point
├── go.mod # Go module definition
├── README.md # This file
└── internal/
├── bech32/ # Bech32 encoding/decoding
│ ├── bech32.go
│ └── bech32_test.go
├── keystore/ # GNOME Keyring integration
│ ├── keystore.go
│ └── keystore_test.go
└── plugin/ # age Identity/Recipient implementation
├── plugin.go
└── plugin_test.go
Dependencies
- Go standard library
filippo.io/age - age encryption library and plugin framework
github.com/godbus/dbus/v5 - D-Bus bindings for native Secret Service communication
github.com/google/go-cmp - for testing
License
MIT License - see LICENSE file for details.
Contributing
Contributions are welcome! Please open an issue or submit a pull request.