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

297 lines
14 KiB
Markdown

# incredigo — local-first credential rotation & custody
`incredigo` discovers credentials scattered across the common places developers and
operators leave them, migrates them into a [gopass](https://www.gopass.pw/)
(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](https://github.com/roddhjav/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](https://github.com/trufflesecurity/trufflehog) / gitleaks | Scan repos/filesystems for high-entropy secrets | Detection, not custody. Finds strings; doesn't normalize, encrypt-migrate, or track. Repo-centric. |
| [Infisical](https://github.com/Infisical/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.
```go
// 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:
```go
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`](https://github.com/awnumar/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).
```go
// 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:
```go
// 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`](https://github.com/FiloSottile/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.
```yaml
# ~/.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.