Today shellflow is a deployment engine, but it has no first-class story for
encrypted configuration. Projects that need it end up with ad-hoc glue:
- an external
age/rageCLI to generate identities and encrypt/decrypt files; - a render script that decrypts secrets, base64-encodes them, and injects them
as literal
# @env KEY=<value>lines so they are masked by shellflow; - a template renderer (
sed) to substitute paths into@copydirectives; - hand-maintained systemd unit
ImportCredential=lists that must stay in sync with the encrypted env files.
All of this knowledge lives in per-project scripts and READMEs instead of in the tool. The result is a fragmented, hard-to-audit workflow where the controller needs several external binaries and every project reinvents the same pipeline.
- Single controller tool. After this change the controller needs only
shellflow(plus thessh/rsync/bashit already wraps). Noage,rage,base64, or render scripts. - Static playbooks. Playbooks reference encrypted files directly
(
# @secrets conf/prod.env.age) instead of generated ones containing base64 literals. - Uniform secret hygiene. Secrets are masked in previews, traces, and audit logs; never appear in any argv; never touch disk on the controller or the targets.
- Zero target-side crypto. Targets only need
bashand systemd ≥ 254.
- Not a replacement for systemd. Credential storage on targets stays
systemd-native (
systemd-creds,LoadCredentialEncrypted=/ImportCredential=).shellflowdecrypts on the controller and lets the target's systemd re-seal per host. - Not a Vault/KMS. Single-operator asymmetric encryption of whole files, consistent with the existing shell-native, minimal-toolchain philosophy.
- No new deployment language.
@secretsbuilds on the existing directive DSL andExecutionPlan; playbooks remain 100% valid Bash.
controller (only shellflow + ssh/rsync/bash) targets (bash + systemd >= 254)
┌───────────────────────────────────────────────┐ ┌──────────────────────────────────┐
│ keys generate / public identity │ │ │
│ secret encrypt / decrypt / edit │ │ │
│ @secrets file.env.age ──decrypt──> env vars │ ssh │ remote bash blocks receive the │
│ (masked everywhere, never in argv) │───────>│ decrypted env (masked) over the │
│ │ │ encrypted channel; targets need │
│ │ │ only bash (+ systemd-creds) │
└───────────────────────────────────────────────┘ └──────────────────────────────────┘
Key pieces:
crates/shellflow-secrets— a new library crate wrapping the Rustagecrate (the reference Rust implementation thatrageitself wraps). Handles identity loading, file encrypt/decrypt, recipient read-back, andKEY=VALUEenv parsing. No I/O leaks intoshellflow-core.- CLI subcommands —
keys,secret; the bare positional form (shellflow deploy.sh) keeps working asrun. @secretsdirective — decrypts an age file at execution time, injects the keys as environment variables into subsequent blocks, exports aLT_SECRET_KEYSlist, and registers every value for masking.
shellflow embeds the Rust age crate. rage is itself a thin CLI over
this crate, so encrypted files are interoperable: files created by
shellflow secret encrypt decrypt with rage, and vice versa.
Identity (private key) is resolved in this order:
-i/--identityflag (any subcommand that needs it);$SHELLFLOW_AGE_IDENTITY;~/.config/age/keys.txt(the age convention).
Identity files are the standard age format (X25519 private key). Existing
rage/age identities work unchanged.
Recipients (public keys) are passed as repeatable -r age1… arguments or
read from a directory of *.pub files via --recipients-dir (one key per
file). This mirrors how projects conventionally store operator public keys.
The age format is anonymous: the header stores the ephemeral share, not the recipients, so recipients cannot be recovered from an encrypted file.
secret edittherefore requires-r/--recipients-dirand fails fast if none are given — re-encrypting without them would silently drop operators.
shellflow run [SCRIPT] [flags] # current behavior; also the default
shellflow keys generate [-o PATH] # write a new identity
shellflow keys public [-i PATH] # print the public key
shellflow secret encrypt [-r age1…]... [-o OUT] FILE
shellflow secret decrypt [-i KEY] [FILE]
shellflow secret edit [-i KEY] FILE # decrypt -> $EDITOR -> re-encrypt
shellflow secret creds FILE # print ImportCredential=KEY lines
run keeps accepting the positional script so shellflow deploy.sh remains
backward compatible; all existing flags (-v/-n/-d/-t/-o/-s/-p/-c/-k, etc.)
apply to run.
#!/usr/bin/env shellflow
# @server web-1 deploy@10.0.0.11
# @server web-2 deploy@10.0.0.12
# @group web web-1,web-2
# @secrets services/myapp/env/prod.env.age
# @remote web
set -eu
# LT_SECRET_KEYS is exported automatically; `${!key}` reads each value.
for key in $LT_SECRET_KEYS; do
printf '%s' "${!key}" | sudo systemd-creds encrypt \
--with-key=host --name="$key" - "/etc/credstore.encrypted/${key}"
doneCredential filenames equal the key names (no
.credsuffix):systemd-credsembeds the output filename into the ciphertext, andImportCredential=KEYlooks up the store by exact name — mismatches are refused. With--local, this block runs on the controller instead of over SSH, which is useful for debugging.
@secrets <file.age>behaves like a batch of@env KEY=VALUEentries applied to all subsequent blocks, but the values are resolved at execution time from the encrypted file.- The directive is a comment, so the playbook remains 100% valid Bash.
- Repeatable: multiple
@secretslines merge in order; later files win on key conflicts (matching shellsourcesemantics). - The decrypted keys are also exported as the space-separated list
variable
LT_SECRET_KEYS, so remote blocks can iterate them without knowing the key names ahead of time.
The parser records only the file path (keeping shellflow-core pure and
I/O-free):
pub struct SecretEntry {
pub file: String,
pub identity: Option<String>,
}
// ExecutionPlan gains: pub secrets: Vec<SecretEntry>The executor resolves all SecretEntry values once, before the first step:
- resolve the identity — most specific wins: the
@secretsline's own--identity, then the run-wide-iflag, then$SHELLFLOW_AGE_IDENTITY, then~/.config/age/keys.txt. Identities are loaded lazily and cached per run, so a playbook whose files each pin their own identity never touches the default path, and a repeated path is read once. A missing or invalid identity is a hard error (never a silent skip); - decrypt the file. Later files win on key conflicts (matching
sourcesemantics) and the key list is deduplicated in first-seen order; - parse
KEY=VALUElines (blank lines and#comments ignored; the first=splits the key); - insert every key into run-state env (same layer as
@export, so explicit@envstill wins on conflicts); - register every value in the
Uimask set (see §5.3); - export
LT_SECRET_KEYSinto run-state env.
Ui::mask_line currently performs a global str::replace for every known
secret. Short values (e.g. 1, on) would corrupt output, so @secrets
masking applies only to values of length ≥ a threshold. The threshold
defaults to 6 and is configurable via $SHELLFLOW_MASK_MIN_LEN (or
--mask-min-len on run, which takes precedence). An unparsable or negative
$SHELLFLOW_MASK_MIN_LEN falls back to 6 rather than failing the run.
Explicit @env KEY=value literals keep their current unconditional masking.
Masking covers host lines, payload previews, and --log-file output through
the existing Ui path; secrets never appear in any spawned argv. Every path
that renders user content must route through the mask, not only the payload
previews: the -vv local bash -c echo goes through Ui::masked_note for
exactly this reason, since a literal secret embedded in a block body would
otherwise be printed unmasked two lines below a preview showing ***.
- Precedence for a key present in several sources:
@env KEY=value(step) >@secrets/@export(run state) > passthrough. - Missing identity, undecryptable file, or malformed env content is a hard error before any step runs (never a silent skip).
| Area | Change |
|---|---|
crates/shellflow-secrets (new) |
age wrapper: identity load, encrypt/decrypt, recipient read-back, env-file parser. Library-only; unit + proptest. |
crates/shellflow-core |
ExecutionPlan.secrets: Vec<SecretEntry>; parser recognizes @secrets (path only, no I/O). |
bin/shellflow |
clap subcommands (run default); executor resolves secrets into run-state env + mask list; Ui gains a min-length mask threshold. |
bin/shellflow/src/preflight.rs |
unchanged (age is embedded; bash/ssh/rsync remain the only required tools). |
Dependency additions (via cargo add, workspace-managed): age,
age-core, clap (already present), tempfile (dev). shellflow-core
stays free of async, I/O, and crypto.
- Controller-only secrets: encrypted files travel to the controller, decrypted in memory, injected into remote payloads over the (already encrypted) SSH channel. Targets never hold the age identity.
- No argv / no disk: secret values never appear in process argv; the temporary plaintext lives only in shellflow's memory and is dropped after the run.
- Masked everywhere: previews,
-vv/-vvvtraces, and--log-fileredact every@secretsvalue (subject to the min-length guard). - Fail fast: missing identity, decrypt failure, or malformed env aborts before any step executes.
- Recipient safety:
secret editrequires explicit-r/--recipients-dirand fails fast otherwise — age is anonymous, so re-encrypting without the full recipient list would silently drop operators.
- Unit (TDD) —
shellflow-secrets: identity load/paths, encrypt→decrypt round-trip with an ephemeral generated identity, recipient read-back, env-file parsing (blank/comment/=-in-value edge cases). - Property — env-file parser invariants via
proptest(e.g. parse round-trip stability), masking threshold behavior. - Parser —
@secretsdirective recognition, ordering, andLT_SECRET_KEYSexport using the existingshellflow-coretest style. - Resolver — a per-entry
--identitybeats the run-wide-i; a playbook whose every entry pins an identity succeeds even when the default identity path does not exist; a missing identity is a hard error naming the path; later files win on key conflicts andLT_SECRET_KEYSis deduplicated; the threshold gates which values are registered for masking. - Integration — extend
bin/shellflow/tests/with mockssh/rsyncshims (existing pattern): a playbook with@secretsmust (a) not leak the value into captured argv or payload previews, (b) run the remote block with the env injected, (c) fail fast without an identity, (d) decrypt through a per-entry--identitywith no run-wide-i, and (e) honorSHELLFLOW_MASK_MIN_LENfor short values. - Mutation —
just mutationover the new crate and parser changes. - Fuzz — conditional: the env-file parser is the only parser-like surface;
add
cargo-fuzzonly if it grows into a general config format.
- M1 —
shellflow-secretscrate.agewrapper + env parser + tests. - M2 —
keys/secretsubcommands.generate,public,encrypt,decrypt,edit,creds. - M3 —
@secretsdirective. Parser entry, executor resolution, masking threshold,LT_SECRET_KEYS. - M4 — Docs & examples. Update
README.md,docs/design.md, add an end-to-end example playbook.
Each milestone ends with just format && just lint && just test && just mutation green.
rage is a CLI wrapper over the same crate; embedding it removes a PATH
dependency, keeps behavior identical, and matches the tool's zero-runtime
philosophy. The Go age implementation is not used.
The directive keeps playbooks static and valid Bash, moves decryption into the tool (where masking already lives), and eliminates render scripts and temporary files.
mask_line does global replacement; protecting short values from being
over-masked requires a threshold, which is exposed as configuration rather
than hard-coded.