v1: discover → migrate → expiry tracking → sealed export/import. - vault: memguard mlock/DONTDUMP arena, handle indirection, zeroize-on-drop - discover: registry + 8 read-only scanners (aws, env, netrc, ssh, docker, kube, git) and a file/dir harvester (--path) - sink: gopass streaming insert; length-prefixed bundle framing; Sealer interface with three impls — age (default, authenticated), hmac (authenticated, openssl-only encrypt-then-MAC), openssl (CBC fallback, unauthenticated; OpenSSL 3.x enc refuses AEAD) - policy: local expiry engine, 60d/8w threshold parser - audit: redacted append-only JSONL, injectable clock - export/import: passphrase from no-echo prompt or $INCREDIGO_PASSPHRASE into locked memory; secrets stream gopass<->Sealer as bytes, never Go strings - tests: scanner leak-checks, vault zeroize, bundle round-trip via fake gopass; go test ./... green, -race clean Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 KiB
incredigo — local-first credential rotation & custody
incredigo discovers credentials scattered across the common places developers and
operators leave them, migrates them into a gopass
(GPG-backed) store without ever writing plaintext to disk, and tracks their age
so you know what is stale. It is local-first, encrypted, and RAM-only by design.
Status: v1 design. v1 scope is discover + migrate + expiry tracking. Provider-driven rotation (issue-new / verify / revoke-old) is explicitly out of scope for v1 and lives behind a stable interface so it can be added later without reshaping the tool.
1. Why this exists (prior art & the gap)
| Tool | What it does | Why it isn't this |
|---|---|---|
| pass-import | Imports from ~60 password managers into pass/gopass | Import-only, and only from other managers. Doesn't discover creds sitting in ~/.aws/credentials, .netrc, .env, kubeconfig, etc. No expiry tracking. |
| TruffleHog / gitleaks | Scan repos/filesystems for high-entropy secrets | Detection, not custody. Finds strings; doesn't normalize, encrypt-migrate, or track. Repo-centric. |
| Infisical / HashiCorp Vault | Full secrets platforms with rotation | Server-based (Postgres/Redis, network daemons). The opposite of local-first / RAM-only. |
| Keeper Commander | Open rotation plugin framework | SaaS-coupled, account-bound. |
| gopass | GPG-backed store + git-credential helper |
The destination, not the discovery/custody/expiry layer. |
The gap: nothing does local discovery of dev/OS credential stores → RAM-only
normalization → encrypted migration into gopass → expiry tracking as a single
offline tool. That is what incredigo is.
2. Design principles
- Local-first. No server, no daemon, no account. Everything runs on your machine against your own files.
- Encrypted at rest, always. Disk only ever holds GPG blobs (gopass) or openssl-sealed backup bundles. Plaintext is never written to disk — no temp files, no swap, no core dumps.
- RAM-only plaintext. Decrypted secret material lives only in a locked, non-swappable, non-dumpable memory arena and is zeroized the moment it is no longer needed.
- Read-only discovery. Scanners never modify the files they read.
- Offline by default. v1 makes zero network calls.
- Built to be extended. Adding a new source (a scanner) or a new backup format (a sealer) is a single self-contained file implementing one interface.
- Auditable, not clever. No custom crypto. We compose GPG (via gopass) and OpenSSL. The whole pipeline can be reproduced by hand with standard CLI tools.
3. Architecture
DISCOVER ─────────────▶ CUSTODY ──────────────▶ SINK
read-only scanners RAM vault (memguard, gopass insert (live store, GPG)
(aws/env/netrc/ssh/ mlock, DONTDUMP, openssl-sealed bundle (backup)
docker/kube/git/...) zeroize-on-drop)
│
▼
EXPIRY TRACKER
(age policy → status report;
flags stale; NO API calls in v1)
Three decoupled stages connected by the RAM vault. Each stage is independently testable and independently extensible.
3.1 Discover
Pluggable, strictly read-only scanners. Each scanner understands exactly one
location/format and emits normalized Credential records.
Implemented in v1: ✅ — others are roadmap.
| Scanner | Location(s) | Parsed | v1 |
|---|---|---|---|
aws |
~/.aws/credentials (AWS_SHARED_CREDENTIALS_FILE) |
INI sections → access key id / secret | ✅ |
env |
.env, .env.* in CWD |
KEY=value, keyword + entropy filtered |
✅ |
netrc |
~/.netrc, ~/.authinfo |
machine / login / password | ✅ |
ssh |
~/.ssh/id_* (not .pub) |
private keys, encrypted flag (best-effort) |
✅ |
docker |
~/.docker/config.json (DOCKER_CONFIG) |
auths[].auth, identitytoken |
✅ |
kube |
~/.kube/config (KUBECONFIG) |
user token / client-key-data / password |
✅ |
git |
~/.git-credentials |
URL-embedded tokens | ✅ |
file |
any file/dir via --path (recursive) |
whole file = one secret; PEM → private_key |
✅ |
gcloud |
~/.config/gcloud/* |
ADC / legacy credentials | — |
npm / pypi |
.npmrc, .pypirc |
_authToken, repo creds |
— |
A scanner returns metadata records; the secret value is a handle into the RAM vault, never a copied plaintext string. See §4.
// internal/discover/discover.go
type Kind string // "aws_key", "token", "password", "private_key", ...
type Credential struct {
Source string // scanner name, e.g. "aws"
Kind Kind
Identity string // non-secret label, e.g. "default / AKIA...redacted"
Location string // file path it came from
Modified time.Time // file mtime — input to expiry policy
Secret *vault.Handle // opaque handle; resolves to locked memory only
Meta map[string]string
}
type Scanner interface {
Name() string
Available() bool // is this source present on the box?
Scan(ctx context.Context, v *vault.Vault) ([]Credential, error)
}
Scanners self-register in an init() so adding one is a single new file:
func init() { discover.Register(&awsScanner{}) }
3.2 Custody — the RAM-only core
This is the security heart of the tool.
- Plaintext secrets live only in a locked memory arena backed by
memguard:mlock(2)— pages never swap to disk.MADV_DONTDUMP— excluded from core dumps.- Guard pages + canaries around buffers.
- Explicit zeroization on drop (
LockedBuffer.Destroy()), and a globalmemguard.CatchInterrupt()/Purge()so secrets are wiped on SIGINT/panic.
- A
vault.Handleis an opaque reference; callers mustOpen()it to get a short-lived*memguard.LockedBuffer, use it, andDestroy()it. Plaintext is never returned as a Gostring(strings are immutable, GC-managed, and cannot be reliably zeroized).
// internal/vault/vault.go
type Vault struct { /* registry of LockedBuffers */ }
type Handle struct { id uint64 }
func (v *Vault) Store(plaintext []byte) *Handle // copies into locked mem, wipes caller's slice
func (v *Vault) Open(h *Handle) (*memguard.LockedBuffer, error)
func (v *Vault) Purge() // destroy everything; called on exit
3.3 Sink
Two sinks, two clearly separated jobs:
a) gopass — the live store (GPG).
Migration streams the secret straight into gopass insert <path> over the
process's stdin pipe. gopass performs GPG encryption to your key. Plaintext
flows RAM-vault → pipe → gopass's GPG layer; it never touches the filesystem.
incredigo ──(os.Pipe, locked buffer)──▶ gopass insert imported/aws/default ──▶ GPG blob on disk
Path convention: <prefix>/<source>/<identity> (default prefix imported/),
e.g. imported/aws/default, imported/docker/registry.example.com.
b) the sealed backup/transport bundle.
gopass already GPG-encrypts every entry, so we do not double-wrap individual
secrets. Instead a Sealer produces a single portable, agent-independent bundle
for disaster recovery / moving to a fresh machine where no gpg-agent or keyring
exists yet. One passphrase restores everything.
The bundle's plaintext side is a flat, length-prefixed record stream
(uint16 pathLen | path | chunk* | end, see internal/sink/bundle.go); secrets
stream gopass show → frame → Sealer and back, so a whole secret is never held in
one buffer or turned into a Go string. The Sealer then wraps that stream:
// internal/sink/sealer.go
type Sealer interface {
Name() string
Seal(ctx context.Context, plaintext io.Reader, out io.Writer, pass *memguard.LockedBuffer) error
Open(ctx context.Context, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error
}
Default: age (--sealer age). OpenSSL 3.x's enc command refuses AEAD
ciphers outright (enc: AEAD ciphers not supported), so the originally-planned
openssl enc -aes-256-gcm cannot authenticate a bundle on a modern box — a tampered
or corrupt backup would decrypt to silent garbage. We therefore default to
age (ChaCha20-Poly1305 + scrypt), which is
authenticated and hand-restorable with the standard age CLI. Three sealers ship,
all behind the one interface:
--sealer |
Authenticated | Mechanism | Hand-restore |
|---|---|---|---|
age (default) |
✅ | age format (ChaCha20-Poly1305, scrypt) | age -d backup.incredigo.age |
hmac |
✅ | openssl enc -aes-256-ctr + in-process HMAC-SHA256 (encrypt-then-MAC, verified before decrypt) |
openssl dgst -hmac then openssl enc -d (see SECURITY.md) |
openssl |
❌ | openssl enc -aes-256-cbc -pbkdf2 |
openssl enc -d -aes-256-cbc -pbkdf2 |
The passphrase is delivered to openssl over an inherited fd (
-pass fd:3), never argv or env. Forageit transits a Go string (the library's API takes astring); forhmacit never does (the MAC key is locked-buffer bytes). Theopensslsealer is a last-resort fallback only: it is unauthenticated.
3.4 Expiry tracker
A pure-local policy engine. No network in v1. Reads Modified (and any
provider-known max-age) and reports against thresholds.
# ~/.config/incredigo/policy.yaml
defaults: { warn_after: 60d, stale_after: 90d }
overrides:
aws_key: { warn_after: 30d, stale_after: 90d }
private_key: { warn_after: 180d }
incredigo status prints a table: identity, source, age, state (ok/warn/stale).
No secrets are ever printed.
4. Threat model & guarantees
| Threat | Mitigation |
|---|---|
| Plaintext leaks to disk | Never written. gopass GPG blobs or openssl bundles only. No temp files. |
| Plaintext leaks via swap | mlock on all secret buffers. |
Plaintext leaks via core dump / /proc/<pid>/mem snapshot |
MADV_DONTDUMP; memguard guard pages; zeroize on drop. |
| Source files corrupted by the tool | Scanners are strictly read-only (open O_RDONLY). |
| Old cred destroyed before replacement verified | v1 never deletes anything. (Rotation, when added, verifies-new before revoke-old.) |
| Secrets in logs | Append-only audit log redacts all secret material; Identity fields are pre-redacted. |
| Network exfiltration | No network calls in v1; future rotation adapters are per-provider, explicit, opt-in. |
Out of scope / honest limits: incredigo cannot protect against a compromised
machine while it runs (a kernel-level attacker or a malicious gpg-agent can see
plaintext in use). It minimizes the window and the footprint, not the
fundamental trust in your own OS and GPG key.
5. CLI surface (v1)
incredigo scan [--source aws,env,...] [--path FILE|DIR ...] [--json] # discover (NO secrets printed)
incredigo migrate [--prefix imported/] [--dedupe] [--force] [--source ...] [--path ...]
incredigo status [--source ...] [--path ...] # expiry/age report against policy
incredigo export [--sealer age|hmac|openssl] --out backup.incredigo.age # sealed bundle of the prefix
incredigo import [--sealer age|hmac|openssl] --in backup.incredigo.age # restore a bundle into gopass
Global flags: --dry-run, --audit-log <path>, --config <path>,
--sealer <age|hmac|openssl>. --path (repeatable) harvests an arbitrary file or
directory via the file scanner. The export/import passphrase is read from a
no-echo prompt or $INCREDIGO_PASSPHRASE into locked memory.
Typical first run:
incredigo scan --dry-run # see what's out there
incredigo migrate --prefix imported/ --dedupe
incredigo status # what's already stale
incredigo export --out ~/incredigo-backup.age # authenticated (age) by default
6. Repository layout
incredigo/
cmd/incredigo/ # cobra entrypoint: scan|migrate|status|export|import
internal/vault/ # memguard arena: Store/Open/Purge, zeroize, SIGINT handler
internal/discover/ # registry + Scanner interface + one file per scanner
aws.go env.go netrc.go ssh.go docker.go kube.go git.go file.go
internal/sink/
gopass.go # streaming writer over gopass insert stdin
bundle.go # plaintext record framing: gopass <-> Sealer
sealer.go # Sealer interface
age.go # default sealer (authenticated)
hmac.go # authenticated openssl-only sealer (encrypt-then-MAC)
openssl.go # unauthenticated fallback sealer (AES-256-CBC)
internal/policy/ # expiry rules, age thresholds, policy.yaml loader
internal/audit/ # redacted append-only log
docs/
ADDING_A_SCANNER.md
SECURITY.md # threat model (mirror of §4) + responsible disclosure
DESIGN.md # this file
README.md
go.mod
The two extension points — the scanner registry and the Sealer interface
— are what make incredigo easy for others to build on.
7. Roadmap beyond v1
- v2 — rotation adapters behind a
Rotatorinterface:aws iam create-access-key→ verify →delete-access-key; GitHub/GitLab PAT via API; SSH keygen + push toauthorized_keys. Verify-new-before-revoke-old, always. - v2 — guided manual rotation for creds with no automatable path (DB passwords).
- Later — TUI for review/approve of discovered creds; gopass mount/git sync hooks.