databox.nvim is a robust and secure Neovim plugin that provides encrypted storage for Lua tables (dictionaries), using age
or compatible encryption tools for cryptographic safety. Data is stored deeply encrypted, meaning every string β including nested keys and values β is protected.
- π Built on age (or compatible tools like rage)
- π§ Supports deeply nested data structures with preserved empty tables and nil values
- π Automatically saves encrypted data to disk
- π§° Simple Lua API for manipulating entries
- π‘οΈ Secure temporary file handling with cryptographically safe practices
- β Comprehensive error handling and validation
- π·οΈ Full LSP support with Lua annotations
- β‘ Efficient single-pass processing with per-string encryption security
- π§ Configurable encryption utilities (age, rage, or custom commands)
- Save sensitive plugin state securely between Neovim sessions
- Store secrets, credentials, or tokens encrypted on disk
- Use as a secure encrypted scratchpad for plugin development
- Maintain encrypted configuration data that persists across sessions
- Store complex nested data structures with preserved empty elements
- Neovim 0.7+
- Encryption utility: One of:
- Public and private key(s) generated (see setup section)
- Unix-like environment (for
mktemp
command)
{
"chrisgve/databox.nvim",
config = function()
local success, err = require("databox").setup({
private_key = "~/.config/age/keys.txt",
public_key = "age1example...", -- Your public key string
-- Optional: Use rage for better performance
-- encryption_cmd = "rage -e -r %s",
-- decryption_cmd = "rage -d -i %s",
})
if not success then
vim.notify("Databox setup failed: " .. err, vim.log.levels.ERROR)
end
end,
}
Using your preferred plugin manager, then configure in your init.lua
:
-- Example with packer.nvim
use {
'chrisgve/databox.nvim',
config = function()
local success, err = require("databox").setup({
private_key = "~/.config/age/keys.txt",
public_key = "age1example...",
})
if not success then
print("Databox setup failed: " .. err)
end
end
}
Generate your age key pair:
# Create age directory
mkdir -p ~/.config/age
# Generate key pair (works with both age and rage)
age-keygen -o ~/.config/age/keys.txt
# Your public key will be displayed in the terminal
# Example: age1abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567890ab
The public key (starting with age1...
) is what you'll use in your configuration.
private_key
(string): Path to your private key filepublic_key
(string): Your public key string or path to public key file
store_path
(string): Custom storage path (defaults to XDG_DATA_HOME or ~/.local/share/nvim/databox.txt)encryption_cmd
(string): Command template for encryption (default:"age -e -a -r %s"
- note the-a
flag for ASCII armor)decryption_cmd
(string): Command template for decryption (default:"age -d -i %s"
)
require("databox").setup({
private_key = "~/.config/age/keys.txt",
public_key = "age1abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567890ab",
})
require("databox").setup({
private_key = "~/.config/age/keys.txt",
public_key = "age1abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567890ab",
encryption_cmd = "rage -e -a -r %s", -- Note: -a flag for ASCII armor
decryption_cmd = "rage -d -i %s",
})
require("databox").setup({
private_key = "~/.config/age/keys.txt",
public_key = "age1abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567890ab",
store_path = "~/my-project/.secrets.txt",
})
require("databox").setup({
private_key = "~/.config/mycrypt/key.pem",
public_key = "~/.config/mycrypt/pub.pem",
encryption_cmd = "mycrypt encrypt --key %s",
decryption_cmd = "mycrypt decrypt --key %s",
})
All functions return (success: boolean, error?: string)
for operations that can fail.
local db = require("databox")
-- Check if plugin is working
local success, err = db.setup({ ... })
-- Set a new key (fails if key exists)
local ok, err = db.set("project1", {
token = "secret123", -- String: encrypted
max_requests = 1000, -- Number: encrypted (protects limits/quotas)
debug_enabled = true, -- Boolean: encrypted (protects config flags)
config = {
lang = "lua",
timeout = 30, -- Nested number: encrypted
features = {}, -- Empty table: encrypted and preserved
disabled_feature = nil -- nil value: encrypted and preserved
}
})
-- Update existing key (fails if key doesn't exist)
local ok, err = db.update("project1", { token = "newsecret456" })
-- Get value (returns value, error)
local value, err = db.get("project1")
-- Check existence
local exists = db.exists("project1") -- true/false
-- Remove key
local ok, err = db.remove("project1")
-- Get all keys
local all_keys = db.keys() -- returns string[]
-- Clear all data
local ok, err = db.clear()
-- Manual save/load
local ok, err = db.save()
local ok, err = db.load()
Most operations auto-save by default. Use save = false
to batch operations:
-- Batch operations without saving each time
db.set("key1", "value1", false)
db.set("key2", "value2", false)
db.set("key3", "value3", false)
-- Save all at once
local ok, err = db.save()
Encrypted data is stored in:
$XDG_DATA_HOME/nvim/databox.txt
If XDG_DATA_HOME
is not set, it defaults to:
$HOME/.local/share/nvim/databox.txt
- Individual encryption: Each sensitive value is encrypted separately, preventing correlation attacks
- Complete data protection: ALL sensitive data types are encrypted:
- Strings:
"secret"
β encrypted - Numbers:
42
β encrypted (protects IDs, seeds, limits, etc.) - Booleans:
true
β encrypted (protects configuration flags) - nil values:
nil
β encrypted (preserves intentional nil values) - Empty tables:
{}
β encrypted (preserves empty structures)
- Strings:
- ASCII armor encoding: Uses age's
-a
flag to produce UTF-8 safe encrypted output for JSON storage - Type preservation: Original data types are perfectly restored after decryption
- Structure preservation: Table structures and nesting are maintained exactly
- Cryptographically secure: Uses
mktemp
for unpredictable temporary file names - Automatic cleanup: Guaranteed cleanup of temporary files, even on failure
- No predictable paths: Eliminates risk of temp file prediction attacks
- Shell injection prevention: All arguments are properly escaped
- Serialization validation: Checks data types before attempting encryption
- Comprehensive errors: Clear, actionable error messages for all failure modes
- Graceful degradation: Partial failures don't corrupt existing data
- Single-pass encoding: Special values (nil, empty tables) are encoded during encryption traversal
- Eliminated redundancy: No separate filtering passes - everything happens in one efficient traversal
- Reliable I/O: Better temporary file handling reduces I/O failure rates
The plugin uses per-string encryption by design - this isn't inefficient, it's a security feature:
- Prevents correlation attacks: Attackers can't correlate similar encrypted values
- Isolated failures: Corruption in one value doesn't affect others
- Individual integrity: Each string has its own encryption envelope
- age: Standard Go implementation, widely compatible
- rage: Rust implementation, often 2-3x faster than age
- Custom tools: Support any age-compatible encryption utility
The plugin provides detailed error messages for common issues:
local ok, err = db.set("existing_key", "value")
if not ok then
print("Error: " .. err) -- "Key already exists: existing_key"
end
local ok, err = db.update("nonexistent_key", "value")
if not ok then
print("Error: " .. err) -- "Key does not exist: nonexistent_key"
end
local ok, err = db.set("test", function() end)
if not ok then
print("Error: " .. err) -- "Cannot serialize function values"
end
- Only string keys are supported at the top level
- Non-serializable values (functions, userdata, threads) are rejected with clear errors
- Requires Unix-like environment with
mktemp
command - Assumes chosen encryption utility (age/rage/custom) is available in PATH
- Command templates must use
%s
placeholder for key parameter
"Plugin not initialized"
- Ensure
setup()
is called before using any other functions - Check that both
private_key
andpublic_key
are provided
"Command failed" or encryption/decryption errors
- Verify your encryption utility (age/rage) is installed and in your PATH
- Check that your key files exist and are readable
- Ensure your public key matches your private key
- Test your encryption utility manually:
echo "test" | age -e -a -r <your_public_key>
(note the-a
flag) - If using custom commands, ensure they produce ASCII-safe output for JSON compatibility
"Failed to create secure temporary file"
- Verify
mktemp
command is available - Check that
/tmp
directory is writable
Performance issues
- Consider switching from
age
torage
for 2-3x performance improvement - Use
save = false
for batch operations to reduce I/O
Enable verbose error output:
-- Check setup status
local success, err = require("databox").setup({ ... })
if not success then
vim.notify("Setup failed: " .. err, vim.log.levels.ERROR)
end
-- Check individual operations
local ok, err = db.set("test", "value")
if not ok then
vim.notify("Set failed: " .. err, vim.log.levels.ERROR)
end
Test your encryption utility choice:
# Test age performance with ASCII armor
time echo "test data" | age -e -a -r <your_public_key> | age -d -i <your_private_key>
# Test rage performance with ASCII armor
time echo "test data" | rage -e -a -r <your_public_key> | rage -d -i <your_private_key>
Tool | Language | Performance | Compatibility | Installation |
---|---|---|---|---|
age | Go | Standard | Universal | brew install age / package managers |
rage | Rust | 2-3x faster | age-compatible | cargo install rage / releases |
Custom | Any | Varies | Must be age-compatible | Your choice |
- For maximum compatibility: Use
age
(default) - For best performance: Use
rage
with custom commands - For specialized needs: Implement age-compatible custom encryption
- BREAKING: Now encrypts ALL sensitive data types, not just strings
- Enhanced security: Numbers, booleans, nil values, and empty tables are now encrypted
- Perfect type preservation: All original data types are restored exactly after decryption
- Use cases: Protects API limits, seeds, configuration flags, IDs, and any numeric secrets
- Migration: Existing data will need to be re-encrypted with the new comprehensive format
- BREAKING: Default encryption now uses ASCII armor (
-a
flag) for UTF-8 safe JSON storage - Fixed: "String contains byte that does not start any UTF-8 character" errors
- Improved: Better compatibility with complex data structures containing varied content
- Migration: Existing data will need to be re-encrypted with the new format
MIT β Β© 2025 @chrisgve