salus
A key/value store protected by secret shares and encryption
Current Releases
libsalus
salusd
salusc
CI/CD
Overview
Salus is a local secret store. It is a key/value store whose master encryption key is split into Shamir secret shares and never persisted to disk.
A long-running daemon (salusd) owns the encrypted database and holds the
reconstructed key only in memory. A command line client (salusc) talks to it
over a local IPC socket; the client holds no key material and performs no crypto.
The project is three workspace crates:
libsalus— shared library: Shamir share generation/unlocking (wraps thesssscrate), the wire protocol (Action/Responseenums and message structs), andsocket_name(), the single source of truth for the IPC socket path.salusd— the daemon: listens on the socket, owns theredbdatabase, and does all AES-256-GCM encryption. The only crate that touches crypto-at-rest and storage.salusc— the CLI client: parses subcommands, connects to the socket, sendsActions, and rendersResponses withcrosstermstyling.
Built with edition 2024, MSRV 1.91.1, and dual-licensed MIT OR Apache-2.0.
Build
Lints are nightly-gated. Each crate root carries a large
#![cfg_attr(nightly, deny(...))]block (clippy::all,clippy::pedantic,missing_docs, …) enabled by abuild.rscfg. On stable these denies are inert, so to actually exercise them lint on nightly:cargo +nightly clippy --all-targets.
Run it end-to-end
-
Start the daemon in the foreground:
(
-eenables stdout logging — for foreground/dev only, not as a service;-vraises verbosity.) -
In another terminal, drive it with the client:
The daemon must be unlocked before store/read succeed (otherwise
StoreNotUnlocked). The reconstructed key auto-clears after key_timeout
seconds (default 20), after which you must unlock again.
Local testing (debug builds)
To exercise debug builds from the project directory without disturbing a
production install on the same machine, keep all state under the tracked
dev/ directory and redirect every path with CLI flags.
Two things must be isolated:
- The socket. On Linux the default IPC socket is an abstract-namespace
name (
salus.sock) that every install shares — a debug daemon would try to bind the same name as a running production daemon. Pointing the socket at a file path (any explicit--socket-path/SALUS_SOCKETvalue) switches to a filesystem socket, so the dev pair gets its own socket while production keeps using the abstract name. Use a path insidedev/. - The database (and config/log). The database, config-file, and tracing
paths are CLI-only (
-d/-c/-t); they are not read from env or the TOML file. Without-d, a debug daemon reads and writes the production database under~/.local/share/salusd/. Always pass-d(and-c/-t) so it stays indev/.
The repo ships base config (dev/salusd.toml, dev/salusc.toml) and a fish
helper that wires these flags up. Source it once per shell:
The wrappers are thin — the equivalent raw commands (for non-fish shells, run from the repo root) are:
# daemon
# client (repeat per command)
Only dev/salusd.toml, dev/salusc.toml, and dev/.gitignore are tracked; the
runtime artifacts (dev/salusd.redb, dev/salusd.log, dev/salus.sock) are
gitignored. The dev config sets a longer key_timeout (300s) so the in-memory
key does not clear out from under you during manual testing.
Stale socket. If the daemon ever fails to start with an "address in use" error after a crash, remove the leftover file socket:
rm -f dev/salus.sock.
Usage
salusd (daemon)
salusd [OPTIONS]
| Flag | Description |
|---|---|
-v, --verbose |
Turn logging up (repeatable; conflicts with --quiet) |
-q, --quiet |
Turn logging down (repeatable; conflicts with --verbose) |
-e, --enable-std-output |
Log to stdout/stderr in addition to the trace file (foreground/dev only — not as a service) |
-c, --config-absolute-path <PATH> |
Absolute path to a non-standard config file |
-t, --tracing-absolute-path <PATH> |
Absolute path to a non-standard tracing output file |
-d, --database-absolute-path <PATH> |
Absolute path to a non-standard database file |
-s, --socket-path <PATH> |
Override the IPC socket path (see SALUS_SOCKET below) |
Configuration is layered, lowest precedence first: a TOML file, then
environment variables, then explicitly-set CLI flags (highest). A CLI flag
left at its default does not override an env/file value, so e.g. SALUSD_VERBOSE
is honored unless you actually pass -v. Any field absent from every source
falls back to its built-in default. Environment variables use the SALUSD_
prefix; single underscores stay within a field name (SALUSD_KEY_TIMEOUT=30 →
key_timeout) and a double underscore descends into a nested table
(SALUSD_TRACING__WITH_TARGET=true → [tracing] with_target). Recognized keys:
| Key | Type | Default | Notes |
|---|---|---|---|
key_timeout |
u64 |
20 |
Seconds before the in-memory key auto-clears. Env/TOML only — no CLI flag. |
socket_path |
string |
— | IPC socket override. Also -s / SALUS_SOCKET. |
verbose / quiet |
u8 |
0 |
Also settable via CLI. |
enable_std_output |
bool |
false |
Also settable via CLI. |
[tracing] |
table | — | with_target, with_thread_ids, with_thread_names, with_line_number, with_level, directives (env: SALUSD_TRACING__WITH_TARGET, …). |
Default paths are per-user and cross-platform via dirs2: config under the
config dir, database under the data dir, and logs under the local data dir, each
in a salusd/ subdirectory — on Linux ~/.config/salusd/,
~/.local/share/salusd/; on macOS ~/Library/Application Support/salusd/. The
IPC socket defaults to a namespaced name where the platform supports it,
otherwise a file under the runtime/temp dir. Set the shared SALUS_SOCKET
environment variable (honored by both the daemon and the client) to relocate the
socket from one place; --socket-path / socket_path override it per process.
salusc (client)
salusc [OPTIONS] <COMMAND>
Global options: -v, --verbose, -q, --quiet, -c, --config-path <PATH>,
-s, --socket-path <PATH>. Like the daemon, the client reads a TOML config file
(<config dir>/salusc/salusc.toml by default) and SALUSC_ environment
variables in addition to CLI flags; it uses SALUS_SOCKET / --socket-path to
find the daemon's socket.
| Command | Description |
|---|---|
shares |
First-time init. Generates and prints the shares once — record them. |
unlock |
Prompts for threshold shares and reconstructs the key in the daemon's memory. |
store |
Store an encrypted value under a key. |
read |
Read and decrypt the value for a key. |
find |
Search keys by regular expression. |
Command options:
shares—-n, --num-shares <N>(default5),-t, --threshold <N>(default3).store—-k, --key <KEY>,-v, --value <VALUE>.read—-k, --key-opt <KEY>.find—<REGEX>(positional).
Architecture
Wire protocol. Client and daemon exchange libsalus::Action / Response
enums (defined in libsalus/src/message/mod.rs), serialized with
bincode-next (standard() config). Each request is a fresh socket
connection: the client writes one encoded Action, half-closes the send side,
and reads the Response to EOF. socket_name(override) is the single source of
truth for the socket path; it resolves an explicit per-side override, then the
shared SALUS_SOCKET env var, then the platform default, keeping the daemon and
client in sync.
Daemon concurrency (salusd/src/runtime/mod.rs). The daemon accepts
connections in a loop. Per connection it spawns two tasks: one decodes the
incoming Action and forwards it over an mpsc channel, the other (an
ActionHandler) consumes the channel and mutates the shared store. The store is
an Arc<Mutex<ShareStore>> shared across all connections; mutex poisoning is
deliberately recovered via into_inner() rather than panicking.
Key/crypto flow (salusd/src/store/mod.rs). A random 32-byte key is
generated at init and split into Shamir shares; the key itself is never stored.
On unlock, submitted shares reconstruct a candidate key, which is verified by
decrypting the sentinel CHECK_KEY record — only then is the key cached in
memory. Stored values are AES-256-GCM sealed with a per-write randomized nonce,
and the key name is bound as additional authenticated data (AAD).
Storage (salusd/src/db/mod.rs). A redb embedded database with two tables:
salus_config (init flag, num_shares, threshold) and salus_store (the sealed
values — a SalusVal row is the nonce plus ciphertext). Access goes through the
generic read_value / write_value helpers.
Security
- The master key is never persisted. It is split into Shamir shares,
reconstructed only in the daemon's memory, and auto-clears after
key_timeout(default 20s). - Key material is zeroized. The reconstructed key is wrapped in
Zeroizing(zeroed on drop), and submitted shares are zeroized after unlock. Key-clearing timers are generation-guarded so a stale timer from an earlier unlock cannot wipe a freshly unlocked key. - Candidate keys are verified. A wrong key fails to open the
CHECK_KEYsentinel, so an incorrect reconstruction is rejected rather than cached. AAD binds every value to its key name, so a relocated/tampered ciphertext fails to decrypt. - Wire-protocol DoS hardening. Decoding is bounded by
MAX_MESSAGE_SIZE(1 MiB, inlibsalus/src/message/mod.rs), so a forged length prefix cannot drive an unbounded allocation. - Fuzzing. The
fuzz/crate provides five libFuzzer targets —fuzz_action_decode,fuzz_response_decode,fuzz_unlock_key,fuzz_store_roundtrip, andfuzz_find_regex— each with a matching regression test. CI audits dependencies and runs fuzz smoke tests (.github/workflows/audit.yml). - The client holds no key material and performs no crypto — all crypto and storage live in the daemon.
Installation
Each release publishes the salusd daemon and the salusc client through
several channels. Every packaged install also ships shell completions, man
pages, an example config, and a systemd user unit for the daemon. Pick the
section for your platform.
Installation (Arch Linux / AUR)
Two AUR packages are available; install either with an AUR helper (e.g. yay,
paru) or manually with makepkg. They install the same binary names and
conflict with each other — only one can be installed at a time.
| Package | Build | Architectures |
|---|---|---|
salus |
Compiles locally from the release source tarball (cargo build) |
x86_64 |
salus-bin |
Pre-compiled static MUSL binaries from the GitHub release | x86_64, aarch64 |
# Pre-compiled binaries (no Rust toolchain required)
# Or build from source (requires rust, cmake, clang)
Install manually with makepkg:
# Pre-compiled binary package
&& &&
# Or the source package
&& &&
Removing:
Installation (Debian / Ubuntu)
Install from the apt repository (recommended)
The signed apt repository at https://rustyhorde.github.io/salus-packages/
tracks every release, so apt upgrade keeps salus current. Packages are built
for amd64 and arm64:
# Add the repository signing key
|
# Add the apt source
|
# Install
Install a downloaded .deb directly
Pre-built .deb packages are attached to each
GitHub release if you prefer not
to add the repository:
# Download to /tmp (substitute the desired version and arch)
VERSION=0.1.1
Note: Place
.debfiles in/tmp/before installing withapt. When reading a local fileaptdrops privileges to the_aptuser, which cannot read files under/home/. Using/tmp/(world-readable) avoids the resulting permission warning. Alternatively,sudo dpkg -i salus_${VERSION}_amd64.debruns as root and works from any location (runsudo apt-get install -fafterwards to resolve dependencies).
Re-running either command with a newer .deb upgrades an existing install.
Removing:
Installation (Fedora / RHEL)
Pre-built .rpm packages for x86_64 and aarch64 are served from the signed
dnf repository at https://rustyhorde.github.io/salus-packages/, so
dnf upgrade keeps salus current:
# Add the repository (imports the signing key on first install)
# Install
On older releases the subcommand is
sudo dnf config-manager addrepo --from-repofile=…, and on dnf 4 you may needsudo dnf install dnf-plugins-corefirst.
.rpm files are also attached to each
GitHub release for direct
installation:
VERSION=0.1.1
Removing:
Installation (cargo)
Requires a Rust toolchain. Install the two binaries directly from crates.io:
Append --version <x.y.z> to install a specific release. A cargo install does
not drop a systemd unit; see the note below to run one as a service.
Homebrew (macOS)
Running salusd as a systemd user service
salusd is a per-user daemon — its database, config, and IPC socket are all
per-user — so it runs as a systemd user service, not a system service. The
salus/salus-bin, .deb, and .rpm packages install salusd.service to
/usr/lib/systemd/user/ with ExecStart=/usr/bin/salusd. Enable it per-user
(no sudo):
The daemon holds the reconstructed key only in memory and clears it after
key_timeout, so after every (re)start you must unlock it again:
Upgrades. Because salusd is a user service, the package manager (which runs as root) cannot restart it for you, and an automatic restart would clear your in-memory key anyway. After upgrading the package, pick up the new binary manually:
Installed via
cargo install? The binary lives at~/.cargo/bin/salusd, not/usr/bin/salusd. Copy the unit from a packaged install (or write your own) and setExecStart=%h/.cargo/bin/salusdbefore enabling it.
Releasing
Releases are driven by a git tag push and handled by
.github/workflows/release.yml:
- Bump the version in
libsalus/,salusd/, andsalusc/Cargo.toml(and thelibsalusdependency version insalusd/salusc), then commit. - Tag and push:
git tag vX.Y.Z && git push --tags. AvX.Y.Z-rc*tag only exercises the build jobs (publishing is skipped) for a dry run. - On a full
vX.Y.Ztag the workflow builds static MUSL + macOS binaries, creates the GitHub release, publishes the crates to crates.io (in dependency order:libsalus→salusd→salusc), updates the AUR PKGBUILDs, publishes the signed apt/rpm repository, and updates the Homebrew tap.
cargo xtask dist salusd|salusc regenerates the shell completions, man pages,
licenses, and (for salusd) the systemd unit and example config under dist/.
The workflow requires these repository secrets: CRATES_IO_TOKEN (crates.io),
AUR_SSH_PRIVATE_KEY (AUR), HOMEBREW_TAP_TOKEN (Homebrew tap),
PACKAGES_REPO_TOKEN + PACKAGES_GPG_PRIVATE_KEY + PACKAGES_GPG_KEY_ID
(signed apt/rpm repo).
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
at your option.
Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.