Files
leetcrypt 425c299359 Initial commit: Incredigo — local-first RAM-only credential custody
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>
2026-06-15 07:57:34 -07:00

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

  1. Local-first. No server, no daemon, no account. Everything runs on your machine against your own files.
  2. 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.
  3. 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.
  4. Read-only discovery. Scanners never modify the files they read.
  5. Offline by default. v1 makes zero network calls.
  6. 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.
  7. 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 global memguard.CatchInterrupt() / Purge() so secrets are wiped on SIGINT/panic.
  • A vault.Handle is an opaque reference; callers must Open() it to get a short-lived *memguard.LockedBuffer, use it, and Destroy() it. Plaintext is never returned as a Go string (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. For age it transits a Go string (the library's API takes a string); for hmac it never does (the MAC key is locked-buffer bytes). The openssl sealer 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 Rotator interface: aws iam create-access-key → verify → delete-access-key; GitHub/GitLab PAT via API; SSH keygen + push to authorized_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.