keyforge

A local secret generator and manager for Windows. Generates cryptographically
strong secrets, stores the ones you were handed elsewhere, rolls either kind on
demand, and keeps the lot in a DPAPI-sealed vault you can search. One binary, no
runtime, no network.
keys # interactive interface
keys new random --len 32 --name stripe-prod --copy
keys add gh-pat --rotate-every 90 # a key someone else issued
keys roll stripe-prod # same name, new value
keys get stripe-prod --show
Install
winget install ElJoshua08.KeyForge
or, for a portable install:
scoop bucket add joshua https://github.com/ElJoshua08/scoop-bucket
scoop install keyforge
or download keyforge--setup.exe from the
releases page and run it.
The installer is per-user: it needs no administrator rights and writes nothing
outside your profile. In full, it creates
%LOCALAPPDATA%\Programs\keyforge with the binaries,
- one Start Menu shortcut,
- one entry in Add/Remove Programs,
- one line in
HKCU\Environment\Path, if you tick the box.
keys notifier enable later adds a per-user scheduled task; nothing else runs in
the background, and no part of this needs elevation. Open a new terminal after
installing — one that was already running keeps the PATH it started with.
Scoop skips all of that and just unpacks the zip.
Uninstalling keeps your vault. The uninstaller asks whether to delete it and
defaults to No; a silent or automated uninstall never asks and never deletes.
The SmartScreen warning
These files are not code-signed, so Windows shows a blue "Windows protected your
PC" box the first time you run the installer. Click More info, then Run
anyway.
That warning means Windows has not seen this file often enough to vouch for it,
not that something is wrong with it. A certificate would mean keeping a signing
key reachable from CI, and for a tool whose job is holding secrets that is the
worse trade — a compromised runner could then sign anything. Instead every file
is built by a public workflow from a public tagged commit, and every release
ships a SHA256SUMS.txt to check against:
(Get-FileHash .\keyforge-0.2.0-setup.exe -Algorithm SHA256).Hash.ToLower()
From source
Released binaries are built for x86_64-pc-windows-msvc. If you have the Visual
Studio C++ build tools and the Windows SDK, that is the whole story:
cargo build --release # -> target\release\keys.exe
If you also cargo install --path ., be aware that ~/.cargo/bin usually sits
near the front of PATH — so a copy installed that way shadows one installed by
winget or Scoop, and keys --version keeps reporting the older build however
many times you upgrade. keys where prints which binary is answering; to remove
the source-installed one:
cargo uninstall keyforge
Without them, build for the GNU target instead — but getrandom and the windows
crate use raw-dylib imports, and rustup's bundled dlltool ships without a
companion as, so you need real mingw-w64 binutils on PATH as well:
winget install Rustlang.Rustup
winget install BrechtSanders.WinLibs.POSIX.UCRT
rustup default stable-x86_64-pc-windows-gnu
cargo build --release
Key types
| Command | Produces |
|---|
keys new random | Random string over hex, base32, base64url, alnum, letters, digits, or full ASCII. --prefix sk_live_, --no-ambiguous |
keys new password | Password with optional --strict mixing of upper, lower, digit, and symbol |
keys new passphrase | EFF large-wordlist diceware, ~12.9 bits per word |
keys new id | UUIDv4, UUIDv7, ULID, nanoid, or a base32 TOTP seed |
keys new keypair | ed25519, RSA 2048/3072/4096 (OpenSSH format), or an age identity |
Every generator reports the entropy it produced, so --len is an informed
choice rather than a guess.
Keys from somewhere else
An API key from a provider's dashboard, a token you were sent, a deploy key you
were given:
keys add gh-pat # prompts; nothing is echoed
keys add deploy-key --stdin < id_ed25519
There is deliberately no flag that takes the value. A secret passed as a
command-line argument lands in your shell history and in the process list, and
cannot be taken back out of either. Multi-line values go through --stdin.
keyforge cannot measure the strength of something it did not generate, so it says
so: a pasted key reads ~128 bits (estimated upper bound) rather than borrowing
the vocabulary it uses for its own output. The estimate assumes every character
was drawn uniformly from the smallest well-known alphabet containing all of them,
which is an upper bound and usually a generous one — it cannot tell password1234
from a random string of the same shape.
Rolling
keys roll stripe-prod # generated: regenerated from its own recipe
keys roll gh-pat # external: prompts for the replacement
keys prev stripe-prod --show # the value it replaced
keys prev stripe-prod --forget
The identity survives: same id, same name, same creation date, same tags. A
generated key is reproduced from the recipe stored alongside it, so a key created
with --prefix sk_live_ --no-ambiguous comes back with both.
The outgoing value is kept — exactly one generation, and only until you roll
again or forget it. That is what makes it safe to roll before you have finished
updating everything that uses the old value, rather than after.
Rotation reminders
keys rotate stripe-prod --every 90
keys ls --due
keys notifier enable # a daily check that raises a Windows toast
Counted from when the value was last set, not from when the entry was last
touched — renaming a key does not reset its clock. keys notifier enable
registers a per-user scheduled task under keyforge\notifier and creates the
Start Menu shortcut Windows requires before it will show a notification from an
unpackaged program. keys notifier disable removes it. A toast names the keys
that are due; it never shows one.
Managing keys
keys ls [--tag api] [--kind random] [--origin external] [--due] [--json]
keys search # fuzzy, over names/tags/kinds
keys get [--show|--copy] [--field public]
keys rm
keys export --out backup.json # plaintext; asks first
keys import backup.json [--overwrite]
keys where # vault location and protection
ls never prints secrets, and --json never includes a retained previous value
either — only the fact that there is one.
Output adapts to its destination. In a terminal you get a labelled block; in a
pipe you get the bare secret, so keys new random --len 32 > .env works.
--vault or the KEYFORGE_VAULT environment variable relocates the
vault.
Interactive
keys with no arguments opens the full-screen interface:
| |
|---|
/ | search |
n / a | generate a key / store one you were given |
R | roll (asks first — r right next to it is reveal) |
p | the previous value |
t | set a rotation reminder |
enter | copy |
r | reveal |
d | delete |
? | the full list |
Secrets stay masked until revealed, re-mask after 15 seconds, and a copied secret
is cleared from the clipboard after 30 seconds (only if it has not been replaced
in the meantime). A retained previous value gets the same treatment, and revealing
a current secret never reveals the retired one.
How it protects things
Randomness comes from the OS CSPRNG via getrandom, and only from there — no
seeded PRNG appears anywhere in the crate. Values are drawn by rejection
sampling, never byte % n, which would make the first eight characters of a
62-symbol alphabet 25% more likely than the rest. tests/uniformity.rs holds
chi-square tests over the generators and a control test that deliberately
introduces modulo bias to prove the check can detect it.
Secrets live in zeroize buffers that wipe on drop, and Debug is implemented
by hand to redact them so a stray {:?} cannot leak one into a log.
At rest, the vault is sealed with Windows DPAPI under your login:
- Another Windows user, or the same file on another machine, cannot read it.
- Any process running as you can. DPAPI unwraps on demand with no prompt, so
malware in your session can call
CryptUnprotectData as easily as keyforge
does. That is the trade for never typing a master password.
If that trade stops being acceptable, vault::crypto::SecretBox is a two-method
trait; a passphrase-derived implementation drops in without touching anything
else.
Saves are atomic: a temp file is flushed, the current vault is renamed to
vault.bak, then the temp file is renamed into place. An interruption can cost
the newest save, never the vault.
The file carries a format version outside the sealed blob, so an older build
refuses a newer vault instead of silently dropping fields it does not know about
and writing them out of existence. One consequence worth knowing: the upgrade
to the 0.2.0 format happens on the first save, and keys get saves in order to
record last-use — so simply reading a key after upgrading commits it. vault.bak
holds the previous revision if you need to go back.
Search only ever sees names, tags, and kinds — secret material is not indexed,
scored, or compared against a query, and there is a test asserting it.
Performance
Measured on this machine with cargo bench, release profile:
| 100 entries | 10,000 entries |
|---|
| search, per keystroke | 19 µs | 0.5–2.1 ms |
| vault load (cold start) | 1.1 ms | 227 ms |
| vault save | 4.4 ms | 225 ms |
Generation is ~0.8 µs for a 32-character secret. Release binary is about 2 MB
and starts in roughly 20 ms.
The 10k-entry load is dominated by DPAPI, not by parsing: sealing 3 MB costs
206 ms of that 227 ms, while JSON accounts for 17 ms. Changing serialization
format would not help. Sealing a 32-byte data key with DPAPI and encrypting the
payload with ChaCha20-Poly1305 would cut it to roughly 20 ms — worth doing only
if a vault ever grows to thousands of entries.
Development
cargo test # 210 tests, ~1s
cargo test --release -- --ignored # RSA keygen; also touches the clipboard
cargo clippy --all-targets -- -D warnings
cargo bench
Layout: keygen/ generates (and is the part worth being paranoid about),
vault/ stores, search.rs ranks, prompt.rs reads a supplied secret without
it touching argv, notify.rs raises the rotation toast, and cli.rs and tui/
are the two front ends over the same core. The module is keygen rather than
gen because gen is a reserved keyword in edition 2024.
keys-notify.exe is a second binary containing nothing but the daily check. It
exists only so it can be a windows-subsystem program: a console program woken by
Task Scheduler flashes a window on screen even when the task is marked hidden.
CI runs the same checks on windows-latest against the MSVC toolchain, which is
the one releases are built with — so the shipping build is exercised on every push
rather than first thing on a release day.
Packaging lives in packaging/, and cutting a version is documented in
RELEASING.md.
License
MIT. See LICENSE.