No description
  • Go 98.1%
  • Just 1.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Claudia 117bd18d24
All checks were successful
ci/woodpecker/push/build Pipeline was successful
chore: sync the files wrangle manages
.woodpecker/release.yml
2026-10-20 21:23:18 +02:00
.just chore: sync the files wrangle manages 2026-10-15 09:35:26 +02:00
.woodpecker chore: sync the files wrangle manages 2026-10-20 21:23:18 +02:00
internal/stash perf: ask the sage agent for the identity directly in env 2026-10-20 20:58:39 +02:00
.gitignore feat: seal, enroll and open token stashes with age 2026-10-15 09:24:59 +02:00
CHANGELOG.md docs: changelog for v0.6.1 2026-10-20 21:01:04 +02:00
config.go feat: keep the identity in sage's keyring 2026-10-16 15:00:21 +02:00
go.mod feat: expand ~ in store and identity paths through gout 2026-10-15 12:51:28 +02:00
go.sum feat: expand ~ in store and identity paths through gout 2026-10-15 12:51:28 +02:00
justfile feat: seal, enroll and open token stashes with age 2026-10-15 09:24:59 +02:00
main.go perf: ask the sage agent for the identity directly in env 2026-10-20 20:58:39 +02:00
main_test.go feat: enroll a listed machine by name 2026-10-16 15:52:04 +02:00
README.md feat: enroll a listed machine by name 2026-10-16 15:52:04 +02:00
sage.go perf: ask the sage agent for the identity directly in env 2026-10-20 20:58:39 +02:00
sage_test.go perf: ask the sage agent for the identity directly in env 2026-10-20 20:58:39 +02:00

locker

A token stash that follows you between machines. Each stash is a dotenv file sealed with age, kept in a directory you already sync — a dotfiles repo does nicely — and opened only by the machines enrolled for it. The plaintext never touches disk: locker env prints it as exports for a shell to eval, which is how moor loads a domain's tokens on every switch.

Install

Requires Go 1.26+ and just.

just install    # builds and copies to ~/.local/bin ($XDG_BIN_HOME)

Usage

locker init                          # mint this machine's identity and list it in the store
locker pubkey                        # print its public key
locker machines                      # the machines listed, and their keys (--json)
locker enroll amedia laptop          # let a listed machine open a stash (run on an enrolled one)
locker enroll amedia laptop age1…    # or name its key, for a machine not listed
locker edit amedia                   # decrypt into $EDITOR, re-seal what you save
                                     # (a new stash is created, this machine enrolled)
locker edit amedia < tokens.env      # or pipe the whole dotenv in
locker revoke amedia laptop          # re-seal without it — then rotate the values
eval "$(locker env amedia)"          # global's exports, then amedia's
token=$(locker get global GITHUB_TOKEN)  # one value, for scripts (amedia's, else global's)
locker show amedia                   # one stash's values (--json)
locker list                          # stashes, their state here, who may open them (--json)
locker                               # status: identity, store, stashes (--json)

env is built to run unattended: a machine with no identity, a stash that does not exist, or one this machine is not enrolled for prints nothing and exits 0, so a shell switching into a domain never breaks over it. Only a stash this machine should open and cannot read is an error.

get is the opposite: asked for one value by name, it fails out loud — a stash or key not there, or a stash this machine is not enrolled for, is exit 1 — so $(locker get …) never quietly hands a script an empty string. It answers what the stash's shell would hold: the stash's own value, else global's.

revoke is forward-only. The stash as sealed before stays in whatever history synced it, readable by the dropped key; rotating the values and sealing the new ones with locker edit is what actually revokes.

Layout

The store is a directory of pairs — the layout the secrets script used, so a store it wrote is one locker reads as is:

<stash>.recipients   age public keys, each under a "# <machine>" line
<stash>.env.age      the sealed dotenv
machines             every machine's public key, in the same format

machines is the store's list of who can be enrolled. locker init adds the machine it runs on, by its short hostname, so once the store has synced a machine already enrolled lets it in by name: locker enroll amedia laptop. Running init again on a machine with an identity mints nothing and only lists it — which is how a machine that predates the list joins it. A machine that lists itself with a new key replaces its old one, and must be enrolled again for the stashes sealed to that.

The stash named global is layered under every other: locker env amedia prints global's exports first, so a key set in both takes amedia's. It is for what every context should have — a personal token, an API key used everywhere — and is listed even before it holds anything; locker edit global starts it.

Each machine has one identity, ~/.config/locker/identity.txt (0600, never synced). It is unencrypted on purpose: env runs inside a shell hook, where nothing can ask for a passphrase.

Identity in sage's keyring

With sage, the identity can live in its keyring instead, so it is not on disk unencrypted:

sage keyring locker add          # from ~/.config/locker/identity.txt
locker status                    # identity: sage's keyring, once the file is gone
rm ~/.config/locker/identity.txt

When the identity file is absent, locker asks sage for it; sage hands it only to the installed locker binary. At a terminal, a locked keyring asks for the keyring passphrase. env never asks — it runs in a shell hook — so while the keyring is locked it prints nothing, as on a machine with no identity; status and the other commands say the keyring did not hand it over. init refuses while the keyring holds an identity, since a new one would shadow the one the stashes are sealed to.

Config

~/.config/locker/config.toml ($XDG_CONFIG_HOME), every key optional:

store    = "~/.local/share/chezmoi/secrets"   # default ~/.local/share/locker
identity = "~/.config/locker/identity.txt"    # the default

--store, --identity and --config override them per run.

Moving from secrets

Nothing is re-sealed. Per machine:

mkdir -p ~/.config/locker && chmod 700 ~/.config/locker
mv ~/.config/secrets/identity.txt ~/.config/locker/identity.txt
printf 'store = "~/.local/share/chezmoi/secrets"\n' > ~/.config/locker/config.toml

then secrets env <stash> becomes locker env <stash>, byte for byte.