salusc 0.1.1

The command line client for the salusd daemon
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
# salus

A key/value store protected by secret shares and encryption

## Current Releases

### libsalus
[![Crates.io](https://img.shields.io/crates/v/libsalus.svg)](https://crates.io/crates/libsalus)
[![Crates.io](https://img.shields.io/crates/l/libsalus.svg)](https://crates.io/crates/libsalus)
[![Crates.io](https://img.shields.io/crates/d/libsalus.svg)](https://crates.io/crates/libsalus)

### salusd
[![Crates.io](https://img.shields.io/crates/v/salusd.svg)](https://crates.io/crates/salusd)
[![Crates.io](https://img.shields.io/crates/l/salusd.svg)](https://crates.io/crates/salusd)
[![Crates.io](https://img.shields.io/crates/d/salusd.svg)](https://crates.io/crates/salusd)

### salusc
[![Crates.io](https://img.shields.io/crates/v/salusc.svg)](https://crates.io/crates/salusc)
[![Crates.io](https://img.shields.io/crates/l/salusc.svg)](https://crates.io/crates/salusc)
[![Crates.io](https://img.shields.io/crates/d/salusc.svg)](https://crates.io/crates/salusc)

### CI/CD
[![docs.rs](https://docs.rs/libsalus/badge.svg)](https://docs.rs/libsalus)
[![codecov](https://codecov.io/gh/rustyhorde/salus/branch/master/graph/badge.svg)](https://codecov.io/gh/rustyhorde/salus)
[![CI](https://github.com/rustyhorde/salus/actions/workflows/salus.yml/badge.svg)](https://github.com/rustyhorde/salus/actions)

## Overview

Salus is a local secret store. It is a key/value store whose master encryption
key is split into [Shamir secret shares][shamir] 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 the
  [`ssss`][ssss] crate), the wire protocol (`Action`/`Response` enums and message
  structs), and `socket_name()`, the single source of truth for the IPC socket path.
- **`salusd`** — the daemon: listens on the socket, owns the [`redb`][redb]
  database, 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, sends
  `Action`s, and renders `Response`s with [`crossterm`][crossterm] styling.

Built with **edition 2024**, MSRV **1.91.1**, and dual-licensed
**MIT OR Apache-2.0**.

## Build

```bash
cargo build                  # build all three crates
cargo build --release
cargo test                   # run all tests
cargo test -p libsalus       # test a single crate
cargo clippy --all-targets   # lints (see note below)
```

> **Lints are nightly-gated.** Each crate root carries a large
> `#![cfg_attr(nightly, deny(...))]` block (`clippy::all`, `clippy::pedantic`,
> `missing_docs`, …) enabled by a `build.rs` cfg. On stable these denies are
> inert, so to actually exercise them lint on nightly:
> `cargo +nightly clippy --all-targets`.

### Run it end-to-end

1. Start the daemon in the foreground:

   ```bash
   cargo run -p salusd -- -e -v
   ```

   (`-e` enables stdout logging — for foreground/dev only, not as a service;
   `-v` raises verbosity.)

2. In another terminal, drive it with the client:

   ```bash
   salusc shares                      # first-time init; prints the shares ONCE — record them
   salusc unlock                      # prompts for `threshold` shares; reconstructs the key in memory
   salusc store -k mykey -v myvalue
   salusc read -k mykey
   salusc find '^my'
   ```

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_SOCKET` value) switches to
  a filesystem socket, so the dev pair gets its own socket while production keeps
  using the abstract name. Use a path inside `dev/`.
- **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 in `dev/`.

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:

```bash
source scripts/dev_env.fish      # defines salusd-dev / salusc-dev

salusd-dev                       # terminal 1: foreground debug daemon
salusc-dev shares                # terminal 2: first-time init — record the shares
salusc-dev unlock                # enter `threshold` shares (default 3)
salusc-dev store -k mykey -v myvalue
salusc-dev read  -k mykey
salusc-dev find '^my'
```

The wrappers are thin — the equivalent raw commands (for non-fish shells, run
from the repo root) are:

```bash
# daemon
cargo run -p salusd -- -e \
    -c dev/salusd.toml -d dev/salusd.redb -t dev/salusd.log -s dev/salus.sock

# client (repeat per command)
cargo run -p salusc -- -c dev/salusc.toml -s dev/salus.sock shares
```

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)

```text
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)

```text
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>` (default `5`), `-t, --threshold <N>` (default `3`).
- `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`][bincode] (`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_KEY`
  sentinel, 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, in `libsalus/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`, and `fuzz_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` |

```bash
# Pre-compiled binaries (no Rust toolchain required)
yay -S salus-bin

# Or build from source (requires rust, cmake, clang)
yay -S salus
```

Install manually with `makepkg`:

```bash
# Pre-compiled binary package
git clone https://aur.archlinux.org/salus-bin.git
cd salus-bin && makepkg -si && cd ..

# Or the source package
git clone https://aur.archlinux.org/salus.git
cd salus && makepkg -si && cd ..
```

Removing:

```bash
sudo pacman -R salus        # or salus-bin
sudo pacman -Rs salus       # also remove now-orphaned dependencies
```

### 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`:

```bash
# Add the repository signing key
sudo install -d /etc/apt/keyrings
curl -fsSL https://rustyhorde.github.io/salus-packages/gpg.key \
    | sudo gpg --dearmor -o /etc/apt/keyrings/salus.gpg

# Add the apt source
echo "deb [arch=amd64,arm64 signed-by=/etc/apt/keyrings/salus.gpg] \
  https://rustyhorde.github.io/salus-packages/apt stable main" \
    | sudo tee /etc/apt/sources.list.d/salus.list

# Install
sudo apt update
sudo apt install salus
```

#### Install a downloaded `.deb` directly

Pre-built `.deb` packages are attached to each
[GitHub release](https://github.com/rustyhorde/salus/releases) if you prefer not
to add the repository:

```bash
# Download to /tmp (substitute the desired version and arch)
VERSION=0.1.1
wget -P /tmp \
    https://github.com/rustyhorde/salus/releases/download/v${VERSION}/salus_${VERSION}_amd64.deb

sudo apt install /tmp/salus_${VERSION}_amd64.deb
```

> **Note**: Place `.deb` files in `/tmp/` before installing with `apt`. When
> reading a local file `apt` drops privileges to the `_apt` user, which cannot
> read files under `/home/`. Using `/tmp/` (world-readable) avoids the resulting
> permission warning. Alternatively, `sudo dpkg -i salus_${VERSION}_amd64.deb`
> runs as root and works from any location (run `sudo apt-get install -f`
> afterwards to resolve dependencies).

Re-running either command with a newer `.deb` upgrades an existing install.

Removing:

```bash
sudo apt remove salus
sudo apt purge salus        # also remove system config files
```

### 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:

```bash
# Add the repository (imports the signing key on first install)
sudo dnf config-manager \
    --add-repo https://rustyhorde.github.io/salus-packages/rpm/salus.repo

# Install
sudo dnf install salus
```

> On older releases the subcommand is
> `sudo dnf config-manager addrepo --from-repofile=…`, and on dnf 4 you may need
> `sudo dnf install dnf-plugins-core` first.

`.rpm` files are also attached to each
[GitHub release](https://github.com/rustyhorde/salus/releases) for direct
installation:

```bash
VERSION=0.1.1
sudo dnf install \
    ./salus-${VERSION}-1.x86_64.rpm        # or salus-${VERSION}-1.aarch64.rpm
```

Removing:

```bash
sudo dnf remove salus
```

### Installation (cargo)

Requires a Rust toolchain. Install the two binaries directly from
[crates.io](https://crates.io):

```bash
cargo install salusd        # the daemon
cargo install salusc        # the client
```

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)

```bash
brew tap rustyhorde/salus
brew install salus
```

### 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`):

```bash
systemctl --user enable --now salusd
```

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:

```bash
salusc shares   # first-time init only — records the shares ONCE
salusc unlock   # reconstruct the key in the daemon's memory
```

> **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:
>
> ```bash
> systemctl --user daemon-reload
> systemctl --user restart salusd
> salusc unlock                       # the key cleared on restart
> ```

> **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 set `ExecStart=%h/.cargo/bin/salusd` before enabling it.

## Releasing

Releases are driven by a git tag push and handled by
`.github/workflows/release.yml`:

1. Bump the version in `libsalus/`, `salusd/`, and `salusc/` `Cargo.toml`
   (and the `libsalus` dependency version in `salusd`/`salusc`), then commit.
2. Tag and push: `git tag vX.Y.Z && git push --tags`. A `vX.Y.Z-rc*` tag only
   exercises the build jobs (publishing is skipped) for a dry run.
3. On a full `vX.Y.Z` tag 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]LICENSE-APACHE or
  <https://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT]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.

[shamir]: https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing
[ssss]: https://crates.io/crates/ssss
[redb]: https://crates.io/crates/redb
[crossterm]: https://crates.io/crates/crossterm
[bincode]: https://crates.io/crates/bincode-next