commit 425c29935936ff9bb02c9e4728bfc5741a531747 Author: leetcrypt Date: Mon Jun 15 07:56:31 2026 -0700 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4515627 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# build output +/credrot +/dist/ + +# never commit secrets or backup bundles (any sealer: age / hmac / openssl) +*.credrot.enc +*.credrot.age +*.age +*.env +.env.* + +# editor / OS +*.swp +.DS_Store diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..e5be06c --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,296 @@ +# 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 ` 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: `//` (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//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 `, `--config `, +`--sealer `. `--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. diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..fb4e4ce --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,45 @@ +# credrot — dev handoff (resume on laptop) + +Handed off from the phone (Termux/proot) session on 2026-06-15. Continue dev here. + +## What this project is +A **local-first, encrypted, RAM-only credential custody tool**. It discovers creds +from common local locations, migrates them into gopass (GPG) **without plaintext +ever touching disk**, and tracks their age. Full architecture + threat model in +[`DESIGN.md`](DESIGN.md); overview in [`README.md`](README.md). + +**Locked decisions (already made with the user — don't relitigate):** +- Language: **Go**. +- v1 scope: **discover + migrate + expiry tracking**. Provider rotation = v2, behind + a `Rotator` interface. +- openssl's role: **sealed export/backup format only** (gopass/GPG is the live + store). No double-wrapping. `Sealer` is an interface so `age` can drop in later. + +## Current state +- ✅ `DESIGN.md` complete. +- ✅ Go scaffold written (~942 LOC). Working: `scan`, `migrate`, `status`; scanners + `aws` + `env`; vault (memguard), gopass sink, openssl sealer, policy, audit. +- ⚠️ **Never compiled** — the phone had no Go toolchain. Treat as unverified. +- 🟡 `export`/`import` are **stubbed** (return "not implemented — see DESIGN.md §3.3"). + +## Next steps (suggested order) +1. **Get to a green build:** `go mod tidy && go build ./cmd/credrot`. Fix whatever + the compiler finds (memguard API surface is the most likely culprit — verify + `NewBufferFromBytes`, `LockedBuffer.Bytes()`, `Destroy`, `CatchInterrupt`, + `Purge` against the installed version). +2. **Implement `export`/`import`** per DESIGN.md §3.3: stream `gopass show` → + `Sealer.Seal` via `io.Pipe`, passphrase from a locked buffer; define the record + format (length-prefixed `path\0secret` is fine). Reverse for import. +3. **Add scanners:** `netrc`, `ssh`, `docker`, `kube`, `git` — see + [`docs/ADDING_A_SCANNER.md`](docs/ADDING_A_SCANNER.md). One file each. +4. **Tests:** table-driven scanner tests with temp-dir fixtures; assert no secret + substrings leak into `Identity`/`Meta`. A vault test asserting zeroize. +5. **Repo hygiene:** `git init`. Per the user's rule, **push to gitea only, never + GitHub.** + +## Gotchas carried over +- Don't copy secret bytes into Go `string`s or heap slices that outlive immediate + use — defeats the RAM-only guarantee. Pipe `vault` buffers straight to stdin. +- Scanners must stay strictly read-only (`O_RDONLY`). +- openssl `enc` GCM/PBKDF2 is finicky across versions; if it fights you, swap the + default `Sealer` to `age` — nothing else changes. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6d3b67 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +
+ +Incredigo — guards your credentials in locked memory, leaves no trace on disk + +# Incredigo + +**Guards your credentials in locked memory. Leaves no trace on disk.** + +*Local-first, encrypted, RAM-only credential custody — shipped as the `incredigo` CLI.* + +
+ +``` ++------------------------------------------------------------+ +| /\ INCREDIGO | +| /<>\ in-CRED-i-GO : credentials, in Go | +| \<>/ Guards your credentials in locked memory. | +| \/ Leaves no trace on disk. | ++------------------------------------------------------------+ +``` + +Incredigo finds credentials scattered across the usual places +(`~/.aws/credentials`, `.env` files, `~/.netrc`, SSH keys, +`~/.docker/config.json`, kubeconfig, …), migrates them into a +[gopass](https://www.gopass.pw/) (GPG-backed) store **without ever writing +plaintext to disk**, and tells you which ones are stale. + +- 🔒 **No plaintext at rest.** Disk only ever holds GPG blobs (gopass) or + openssl-sealed backups. +- 🧠 **RAM-only secrets.** Decrypted material lives in `mlock`'d, non-dumpable, + zeroized memory ([memguard](https://github.com/awnumar/memguard)). +- 📴 **Offline.** v1 makes zero network calls. +- 👀 **Read-only discovery.** Scanners never touch your source files. +- 🧩 **Hackable.** A new source is one file; the backup format is an interface. + +See [`DESIGN.md`](DESIGN.md) for the architecture and threat model. + +## Why the name + +**Incredigo** is one word hiding three: + +``` + in · CRED · i · GO + └┬─┘ └┬┘ + CREDentials in GO +``` + +It manages your **CRED**entials, it's written in **GO**, and read aloud the whole +thing lands on *incredible* — which is the bar a credential tool that promises +"no plaintext on disk, ever" has to clear. + +### The mark + +The logo is **Strata** — nested diamonds tightening around a single bright core. +It's the whole tool in one glyph: each ring is a layer of custody (read-only +discovery → locked-memory vault → GPG/OpenSSL at rest), and the core is your +secret, held in RAM and never written out. The design is deliberately abstract and +geometric — no mascot, no metaphor to decode. + +| What it does | How | +|---|---| +| **Finds, doesn't guess** | Real parsers per source — no blind grep, no false positives. | +| **Composes, doesn't invent** | Battle-tested GPG (via gopass) and OpenSSL; no home-rolled crypto to break. | +| **Holds, doesn't leak** | Read-only discovery, RAM-only custody, redacted audit log — plaintext never leaves the locked arena. | +| **Works, then vanishes** | No server, daemon, network, or temp files; it does its job and leaves nothing on disk. | + +The brand is **Incredigo**, and so is the command: the CLI was renamed from its old +working title `credrot` so the tool and the project finally share a name. + +## Status + +v1 scope: **discover + migrate + expiry tracking**. Provider-driven rotation +(issue-new / verify / revoke-old) is planned for v2 behind a stable interface. + +`export`/`import` are stubbed in this scaffold (see `cmd/incredigo/main.go`). + +> **Rename in progress:** the Go package and binary are being renamed +> `credrot` → `incredigo`. Until that lands, substitute `./cmd/credrot` / +> `credrot` in the commands below. + +## Build + +Requires Go 1.22+. + +```sh +go mod tidy +go build ./cmd/incredigo +``` + +## Usage + +```sh +incredigo scan --dry-run # see what's out there (no secrets printed) +incredigo migrate --prefix imported/ --dedupe +incredigo status # age vs policy +incredigo export --out ~/incredigo-backup.enc # (v1 stub) +``` + +## Layout + +| Path | Purpose | +|---|---| +| `cmd/incredigo` | cobra CLI | +| `internal/vault` | RAM-only secret arena (memguard) | +| `internal/discover` | scanner registry + one file per source | +| `internal/sink` | gopass writer + Sealer interface + openssl impl | +| `internal/policy` | expiry rules | +| `internal/audit` | redacted append-only log | + +## Contributing + +Adding a source is intentionally easy — see +[`docs/ADDING_A_SCANNER.md`](docs/ADDING_A_SCANNER.md). diff --git a/cmd/incredigo/main.go b/cmd/incredigo/main.go new file mode 100644 index 0000000..8f927bf --- /dev/null +++ b/cmd/incredigo/main.go @@ -0,0 +1,426 @@ +// Command incredigo is a local-first, encrypted, RAM-only credential custody tool. +// +// v1 subcommands: scan, migrate, status, export, import. +// See DESIGN.md for the full architecture and threat model. +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "incredigo/internal/audit" + "incredigo/internal/discover" + "incredigo/internal/policy" + "incredigo/internal/sink" + "incredigo/internal/vault" +) + +var ( + flagSources []string + flagPaths []string + flagPrefix string + flagDedupe bool + flagForce bool + flagJSON bool + flagDryRun bool + flagAuditLog string + flagConfig string + flagSealer string +) + +func main() { + root := &cobra.Command{ + Use: "incredigo", + Short: "Local-first, encrypted, RAM-only credential custody", + SilenceUsage: true, + SilenceErrors: true, + } + root.PersistentFlags().BoolVar(&flagDryRun, "dry-run", false, "do not write anything") + root.PersistentFlags().StringVar(&flagAuditLog, "audit-log", "", "append-only redacted audit log path") + root.PersistentFlags().StringVar(&flagConfig, "config", "", "policy.yaml path") + root.PersistentFlags().StringVar(&flagSealer, "sealer", "age", "backup sealer: age (default, authenticated), hmac (authenticated, openssl-only), or openssl (unauthenticated)") + + root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd()) + + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "incredigo:", err) + os.Exit(1) + } +} + +// applyPaths registers any --path targets with the file scanner and, when an +// explicit --source filter is in play, makes sure "file" is included so --path +// just works alongside it. +func applyPaths() { + for _, p := range flagPaths { + discover.AddPath(p) + } + if len(flagPaths) == 0 || len(flagSources) == 0 { + return + } + for _, s := range flagSources { + if s == "file" { + return + } + } + flagSources = append(flagSources, "file") +} + +// withVault sets up the RAM vault and guarantees it is purged on exit. +func withVault(fn func(ctx context.Context, v *vault.Vault) error) error { + v := vault.New() + defer v.Purge() + return fn(context.Background(), v) +} + +func scanCmd() *cobra.Command { + c := &cobra.Command{ + Use: "scan", + Short: "Discover credentials (no secrets printed)", + RunE: func(cmd *cobra.Command, args []string) error { + applyPaths() + return withVault(func(ctx context.Context, v *vault.Vault) error { + creds, err := discover.ScanAll(ctx, v, flagSources...) + if err != nil { + return err + } + log, _ := audit.Open(flagAuditLog, time.Now) + defer log.Close() + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(w, "SOURCE\tKIND\tIDENTITY\tLOCATION") + for _, cr := range creds { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", cr.Source, cr.Kind, cr.Identity, cr.Location) + log.Write(audit.Entry{Action: "scan", Source: cr.Source, + Kind: string(cr.Kind), Identity: cr.Identity, Location: cr.Location, Outcome: "ok"}) + } + w.Flush() + fmt.Fprintf(os.Stderr, "\n%d credential(s) found across %d source(s)\n", + len(creds), countSources(creds)) + return nil + }) + }, + } + c.Flags().StringSliceVar(&flagSources, "source", nil, "limit to these scanners (comma-separated)") + c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to harvest (repeatable)") + c.Flags().BoolVar(&flagJSON, "json", false, "json output") + return c +} + +func migrateCmd() *cobra.Command { + c := &cobra.Command{ + Use: "migrate", + Short: "Migrate discovered credentials into gopass (plaintext never hits disk)", + RunE: func(cmd *cobra.Command, args []string) error { + applyPaths() + return withVault(func(ctx context.Context, v *vault.Vault) error { + gp := &sink.Gopass{Prefix: flagPrefix} + if !gp.Available() { + return fmt.Errorf("gopass not found on PATH") + } + creds, err := discover.ScanAll(ctx, v, flagSources...) + if err != nil { + return err + } + log, _ := audit.Open(flagAuditLog, time.Now) + defer log.Close() + + var done, skipped int + for _, cr := range creds { + sp := gp.StorePath(cr) + if flagDedupe && gp.Exists(ctx, sp) { + skipped++ + fmt.Printf("skip %s (exists)\n", sp) + log.Write(audit.Entry{Action: "migrate", Source: cr.Source, Kind: string(cr.Kind), + Identity: cr.Identity, Outcome: "skipped"}) + continue + } + if flagDryRun { + fmt.Printf("would %s\n", sp) + continue + } + if err := gp.Insert(ctx, v, cr, flagForce); err != nil { + log.Write(audit.Entry{Action: "migrate", Source: cr.Source, Kind: string(cr.Kind), + Identity: cr.Identity, Outcome: err.Error()}) + return err + } + done++ + fmt.Printf("store %s\n", sp) + log.Write(audit.Entry{Action: "migrate", Source: cr.Source, Kind: string(cr.Kind), + Identity: cr.Identity, Outcome: "ok"}) + } + fmt.Fprintf(os.Stderr, "\nmigrated %d, skipped %d\n", done, skipped) + return nil + }) + }, + } + c.Flags().StringSliceVar(&flagSources, "source", nil, "limit to these scanners") + c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to harvest (repeatable)") + c.Flags().StringVar(&flagPrefix, "prefix", "imported/", "gopass path prefix") + c.Flags().BoolVar(&flagDedupe, "dedupe", false, "skip entries that already exist") + c.Flags().BoolVar(&flagForce, "force", false, "overwrite existing entries") + return c +} + +func statusCmd() *cobra.Command { + c := &cobra.Command{ + Use: "status", + Short: "Report credential age vs expiry policy", + RunE: func(cmd *cobra.Command, args []string) error { + applyPaths() + pol, err := policy.Load(flagConfig) + if err != nil { + return err + } + return withVault(func(ctx context.Context, v *vault.Vault) error { + creds, err := discover.ScanAll(ctx, v, flagSources...) + if err != nil { + return err + } + now := time.Now() + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(w, "STATE\tAGE\tSOURCE\tIDENTITY") + for _, cr := range creds { + st, age := pol.Evaluate(cr, now) + fmt.Fprintf(w, "%s\t%dd\t%s\t%s\n", strings.ToUpper(string(st)), + int(age.Hours()/24), cr.Source, cr.Identity) + } + w.Flush() + return nil + }) + }, + } + c.Flags().StringSliceVar(&flagSources, "source", nil, "limit to these scanners") + c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to harvest (repeatable)") + return c +} + +func exportCmd() *cobra.Command { + var out string + c := &cobra.Command{ + Use: "export", + Short: "Write a sealed backup bundle of the gopass prefix", + RunE: func(cmd *cobra.Command, args []string) error { + gp := &sink.Gopass{Prefix: flagPrefix} + if !gp.Available() { + return fmt.Errorf("gopass not found on PATH") + } + sealer, err := sealerFor(flagSealer) + if err != nil { + return err + } + if flagDryRun { + paths, err := gp.ListPaths(context.Background(), flagPrefix) + if err != nil { + return err + } + for _, p := range paths { + fmt.Printf("would export %s\n", p) + } + fmt.Fprintf(os.Stderr, "\n%d entr(ies) under %q\n", len(paths), flagPrefix) + return nil + } + + f, err := os.OpenFile(out, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("create %s (use a fresh path; export refuses to overwrite): %w", out, err) + } + defer f.Close() + + return withVault(func(ctx context.Context, v *vault.Vault) error { + h, err := readPassphrase(v, true) + if err != nil { + return err + } + pass, err := v.Open(h) + if err != nil { + return err + } + + // gopass show -> framed plaintext -> pipe -> Sealer -> file. + pr, pw := io.Pipe() + var n int + go func() { + var gerr error + n, gerr = gp.ExportTo(ctx, pw, flagPrefix) + pw.CloseWithError(gerr) + }() + if err := sealer.Seal(ctx, pr, f, pass); err != nil { + return err + } + log, _ := audit.Open(flagAuditLog, time.Now) + defer log.Close() + log.Write(audit.Entry{Action: "export", Outcome: "ok", + Location: out, Source: sealer.Name()}) + fmt.Fprintf(os.Stderr, "sealed %d entr(ies) with %s -> %s\n", n, sealer.Name(), out) + return nil + }) + }, + } + c.Flags().StringVar(&out, "out", "backup.incredigo.age", "output bundle path (refuses to overwrite)") + c.Flags().StringVar(&flagPrefix, "prefix", "imported/", "gopass prefix to export") + return c +} + +func importCmd() *cobra.Command { + var in string + c := &cobra.Command{ + Use: "import", + Short: "Restore a sealed bundle into gopass", + RunE: func(cmd *cobra.Command, args []string) error { + gp := &sink.Gopass{Prefix: flagPrefix} + if !gp.Available() { + return fmt.Errorf("gopass not found on PATH") + } + sealer, err := sealerFor(flagSealer) + if err != nil { + return err + } + f, err := os.Open(in) + if err != nil { + return err + } + defer f.Close() + + return withVault(func(ctx context.Context, v *vault.Vault) error { + h, err := readPassphrase(v, false) + if err != nil { + return err + } + pass, err := v.Open(h) + if err != nil { + return err + } + + // file -> Sealer.Open -> pipe -> framed plaintext -> gopass insert. + pr, pw := io.Pipe() + go func() { + pw.CloseWithError(sealer.Open(ctx, f, pw, pass)) + }() + n, err := gp.ImportFrom(ctx, pr, flagForce) + if err != nil { + return err + } + log, _ := audit.Open(flagAuditLog, time.Now) + defer log.Close() + log.Write(audit.Entry{Action: "import", Outcome: "ok", + Location: in, Source: sealer.Name()}) + fmt.Fprintf(os.Stderr, "restored %d entr(ies) from %s\n", n, in) + return nil + }) + }, + } + c.Flags().StringVar(&in, "in", "backup.incredigo.age", "input bundle path") + c.Flags().BoolVar(&flagForce, "force", false, "overwrite existing gopass entries") + return c +} + +// sealerFor selects the backup sealer. age is the authenticated default; openssl +// is an unauthenticated fallback (see internal/sink/openssl.go). +func sealerFor(name string) (sink.Sealer, error) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "", "age": + return &sink.Age{}, nil + case "hmac": + return &sink.HMACSealer{}, nil + case "openssl": + return &sink.OpenSSL{}, nil + default: + return nil, fmt.Errorf("unknown sealer %q (want: age, hmac, openssl)", name) + } +} + +// readPassphrase obtains a backup passphrase into the locked vault and returns its +// handle. Sources, in order: $INCREDIGO_PASSPHRASE (automation), an interactive +// no-echo TTY prompt, or one line from non-terminal stdin (piped). On a TTY, +// confirm re-prompts to guard against typos in a backup passphrase. +func readPassphrase(v *vault.Vault, confirm bool) (*vault.Handle, error) { + if env, ok := os.LookupEnv("INCREDIGO_PASSPHRASE"); ok { + if env == "" { + return nil, fmt.Errorf("INCREDIGO_PASSPHRASE is set but empty") + } + return v.Store([]byte(env)), nil + } + + fd := int(os.Stdin.Fd()) + if !term.IsTerminal(fd) { + // piped stdin: read a single line as the passphrase + line, err := readLine(os.Stdin) + if err != nil { + return nil, err + } + if len(line) == 0 { + return nil, fmt.Errorf("empty passphrase on stdin") + } + return v.Store(line), nil + } + + fmt.Fprint(os.Stderr, "Passphrase: ") + pw1, err := term.ReadPassword(fd) + fmt.Fprintln(os.Stderr) + if err != nil { + return nil, err + } + if len(pw1) == 0 { + return nil, fmt.Errorf("empty passphrase") + } + if confirm { + fmt.Fprint(os.Stderr, "Confirm passphrase: ") + pw2, err := term.ReadPassword(fd) + fmt.Fprintln(os.Stderr) + if err != nil { + wipe(pw1) + return nil, err + } + eq := bytes.Equal(pw1, pw2) + wipe(pw2) + if !eq { + wipe(pw1) + return nil, fmt.Errorf("passphrases do not match") + } + } + return v.Store(pw1), nil // Store wipes pw1 +} + +// readLine reads bytes up to (not including) the first newline. +func readLine(r io.Reader) ([]byte, error) { + var out []byte + b := make([]byte, 1) + for { + n, err := r.Read(b) + if n > 0 { + if b[0] == '\n' { + return out, nil + } + out = append(out, b[0]) + } + if err == io.EOF { + return out, nil + } + if err != nil { + return out, err + } + } +} + +func wipe(b []byte) { + for i := range b { + b[i] = 0 + } +} + +func countSources(creds []discover.Credential) int { + seen := map[string]struct{}{} + for _, c := range creds { + seen[c.Source] = struct{}{} + } + return len(seen) +} diff --git a/docs/ADDING_A_SCANNER.md b/docs/ADDING_A_SCANNER.md new file mode 100644 index 0000000..f2d1ba8 --- /dev/null +++ b/docs/ADDING_A_SCANNER.md @@ -0,0 +1,86 @@ +# Adding a scanner + +A scanner teaches incredigo to discover credentials from one location/format. Each +is a single self-contained file in `internal/discover/` that implements the +`Scanner` interface and registers itself in `init()`. + +## The contract + +```go +type Scanner interface { + Name() string + Available() bool + Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) +} +``` + +Rules: + +1. **Read-only, always.** Open files with `os.Open` (O_RDONLY). Never write, + rename, or truncate a source file. +2. **Never build plaintext into a long-lived value.** Read the secret into a + `[]byte`, hand it to `v.Store(secret)`, and put the returned `*vault.Handle` in + `Credential.Secret`. `Store` wipes your slice for you. +3. **Pre-redact identities.** `Identity` and any `Meta` values are printed and + logged. Use `redactMiddle(...)` (see `aws.go`) for key ids etc. Never put the + secret in `Identity`/`Meta`. +4. **Set `Modified`** from the file mtime — it feeds the expiry policy. +5. **`Available()` should be cheap** (a `stat`), and return false when the source + is absent so the run skips it cleanly. + +## Template + +```go +package discover + +import ( + "context" + "os" + "path/filepath" + + "incredigo/internal/vault" +) + +func init() { Register(&fooScanner{}) } + +type fooScanner struct{} + +func (f *fooScanner) Name() string { return "foo" } + +func (f *fooScanner) path() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".foo", "credentials") +} + +func (f *fooScanner) Available() bool { + _, err := os.Stat(f.path()) + return err == nil +} + +func (f *fooScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) { + p := f.path() + b, err := os.ReadFile(p) // read-only + if err != nil { + return nil, err + } + fi, _ := os.Stat(p) + + secret := parseSecret(b) // your format logic + return []Credential{{ + Source: f.Name(), + Kind: KindToken, + Identity: "foo / " + redactMiddle(/* a non-secret id */), + Location: p, + Modified: fi.ModTime(), + Secret: v.Store([]byte(secret)), + }}, nil +} +``` + +That's it — the registry picks it up automatically, and `scan`/`migrate`/`status` +all gain the new source with no other changes. + +## Tests + +Add `foo_test.go` with a fixture file in a temp dir; assert that `Scan` returns +the expected metadata and that `Identity`/`Meta` contain no secret substrings. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..229b2af --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,61 @@ +# Security model + +incredigo's job is to *minimize the footprint and lifetime* of plaintext secrets. +It is not a substitute for a trustworthy machine. + +## Guarantees + +| Threat | Mitigation | +|---|---| +| Plaintext leaks to disk | Never written. gopass GPG blobs or sealed bundles (age/hmac/openssl) only; no temp files. | +| Plaintext leaks via swap | `mlock` on every secret buffer (memguard). | +| Plaintext via core dump / process snapshot | `MADV_DONTDUMP`, guard pages, zeroize on drop; `memguard.CatchInterrupt`. | +| Source files corrupted by the tool | Scanners are strictly read-only (`O_RDONLY`). | +| Old cred destroyed before replacement verified | v1 deletes nothing. (v2 rotation verifies-new before revoke-old.) | +| Secrets in logs | Audit log records only redacted metadata; identities are pre-redacted by scanners. | +| Secrets in `ps`/argv | Secrets are piped via stdin (gopass); the backup passphrase reaches openssl over an inherited fd (`-pass fd:3`). Never argv, never env. | +| Network exfiltration | No network calls in v1. | + +## Honest limits + +- incredigo cannot protect plaintext from a **compromised OS/kernel** or a malicious + **gpg-agent** while it runs — those already see your secrets in normal use. +- The sealed bundle's strength is its **passphrase**. Use a strong one; the KDF is + scrypt (`age`, default) or PBKDF2 @ 600k iterations (`hmac`/`openssl`), but a weak + passphrase is still a weak passphrase. +- Go's runtime can in principle copy bytes during GC compaction. memguard buffers + live outside the Go heap to avoid this; do not copy secret bytes into Go + `string`s or heap `[]byte` that outlive immediate use. One documented exception: + the `age` sealer's library API takes the passphrase as a `string` (the `hmac` + sealer does not). The vaulted secrets themselves never become strings. + +## Restoring a bundle by hand + +If `incredigo` itself is unavailable, a bundle is recoverable with standard tools. +The decrypted stream is the length-prefixed record format in +`internal/sink/bundle.go` (`uint16 pathLen | path | uint32 chunkLen | chunk | …`). + +**age (default):** + +```sh +age -d backup.incredigo.age > bundle.bin # prompts for the passphrase +``` + +**hmac (`--sealer hmac`)** — verify integrity *before* decrypting: + +```sh +head -c 32 backup.incredigo.age > mac.bin # bundle = HMAC-SHA256(32) || ciphertext +tail -c +33 backup.incredigo.age > ct.bin +openssl dgst -sha256 -hmac "$PASS" -binary ct.bin | cmp - mac.bin # must match +openssl enc -d -aes-256-ctr -pbkdf2 -iter 600000 -pass pass:"$PASS" -in ct.bin > bundle.bin +``` + +**openssl (`--sealer openssl`, unauthenticated):** + +```sh +openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -in backup.incredigo.age > bundle.bin +``` + +## Reporting + +Report vulnerabilities privately to the maintainer (do not open a public issue). diff --git a/docs/assets/_igo_options.html b/docs/assets/_igo_options.html new file mode 100644 index 0000000..9125e59 --- /dev/null +++ b/docs/assets/_igo_options.html @@ -0,0 +1,91 @@ + +
+ + +
+ + + + + + + + + + + + + + + go + +
A · DIAMOND-DOT
dark tile · i-dot = Strata core
+
+ + +
+ + + + + + + + + go + +
B · KNOCKOUT
orange tile · bold solid
+
+ + +
+ + + + + + + + + + + + + + igo + +
C · STACK
Strata mark over igo
+
+ + +
+ + + + + + + + + ig + + + + + + +
D · DIAMOND-O
the 'o' becomes the vault
+
+ +
+ diff --git a/docs/assets/_igo_options.png b/docs/assets/_igo_options.png new file mode 100644 index 0000000..ab3de0a Binary files /dev/null and b/docs/assets/_igo_options.png differ diff --git a/docs/assets/banner.png b/docs/assets/banner.png new file mode 100644 index 0000000..6e86d56 Binary files /dev/null and b/docs/assets/banner.png differ diff --git a/docs/assets/banner.svg b/docs/assets/banner.svg new file mode 100644 index 0000000..720dedd --- /dev/null +++ b/docs/assets/banner.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + inCREDiGO + Guards your credentials in locked memory. + Leaves no trace on disk. + + + + + + local-first + + + + RAM-only + + + + offline + + + + no plaintext at rest + + + diff --git a/docs/assets/header.txt b/docs/assets/header.txt new file mode 100644 index 0000000..3cc3974 --- /dev/null +++ b/docs/assets/header.txt @@ -0,0 +1,6 @@ ++------------------------------------------------------------+ +| /\ INCREDIGO | +| /<>\ in-CRED-i-GO : credentials, in Go | +| \<>/ Guards your credentials in locked memory. | +| \/ Leaves no trace on disk. | ++------------------------------------------------------------+ diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000..6a43f64 Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg new file mode 100644 index 0000000..691630d --- /dev/null +++ b/docs/assets/logo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d6c9cd3 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module incredigo + +go 1.25.0 + +require ( + filippo.io/age v1.3.1 + github.com/awnumar/memguard v0.22.5 + github.com/spf13/cobra v1.8.1 + golang.org/x/term v0.37.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + filippo.io/hpke v0.4.0 // indirect + github.com/awnumar/memcall v0.2.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sys v0.46.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f8e7cd4 --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= +github.com/awnumar/memcall v0.2.0 h1:sRaogqExTOOkkNwO9pzJsL8jrOV29UuUW7teRMfbqtI= +github.com/awnumar/memcall v0.2.0/go.mod h1:S911igBPR9CThzd/hYQQmTc9SWNu3ZHIlCGaWsWsoJo= +github.com/awnumar/memguard v0.22.5 h1:PH7sbUVERS5DdXh3+mLo8FDcl1eIeVjJVYMnyuYpvuI= +github.com/awnumar/memguard v0.22.5/go.mod h1:+APmZGThMBWjnMlKiSM1X7MVpbIVewen2MTkqWkA/zE= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..8f879a4 --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,67 @@ +// Package audit writes an append-only, secret-redacted record of what incredigo +// found and did. It never logs secret material — only metadata already known to +// be non-secret (source, kind, redacted identity, location, action, outcome). +package audit + +import ( + "encoding/json" + "io" + "os" + "time" +) + +type Entry struct { + Time time.Time `json:"time"` + Action string `json:"action"` // "scan" | "migrate" | "status" | "export" | "import" + Source string `json:"source,omitempty"` + Kind string `json:"kind,omitempty"` + Identity string `json:"identity,omitempty"` // already redacted upstream + Location string `json:"location,omitempty"` + Outcome string `json:"outcome,omitempty"` // "ok" | "skipped" | error string +} + +type Log struct { + w io.WriteCloser + now func() time.Time +} + +// Open appends to path. An empty path yields a no-op log. clock supplies entry +// timestamps (defaults to time.Now when nil) and is injectable for tests. +func Open(path string, clock func() time.Time) (*Log, error) { + if clock == nil { + clock = time.Now + } + if path == "" { + return &Log{now: clock}, nil + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, err + } + return &Log{w: f, now: clock}, nil +} + +// Write appends one JSON-lines entry. Caller is responsible for keeping Identity +// redacted; this package does not inspect or sanitize values. +func (l *Log) Write(e Entry) error { + if l.w == nil { + return nil + } + if e.Time.IsZero() { + e.Time = l.now() + } + b, err := json.Marshal(e) + if err != nil { + return err + } + b = append(b, '\n') + _, err = l.w.Write(b) + return err +} + +func (l *Log) Close() error { + if l.w == nil { + return nil + } + return l.w.Close() +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go new file mode 100644 index 0000000..0c00808 --- /dev/null +++ b/internal/audit/audit_test.go @@ -0,0 +1,48 @@ +package audit + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWriteUsesInjectedClock(t *testing.T) { + p := filepath.Join(t.TempDir(), "audit.log") + fixed := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + + log, err := Open(p, func() time.Time { return fixed }) + if err != nil { + t.Fatal(err) + } + if err := log.Write(Entry{Action: "scan", Source: "aws", Identity: "default / AKIA***", Outcome: "ok"}); err != nil { + t.Fatal(err) + } + log.Close() + + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + out := string(b) + if !strings.Contains(out, "2026-01-02T03:04:05Z") { + t.Errorf("injected clock not used: %s", out) + } + if !strings.Contains(out, `"action":"scan"`) || !strings.Contains(out, `"source":"aws"`) { + t.Errorf("entry not serialized as expected: %s", out) + } +} + +func TestNoopLogWhenPathEmpty(t *testing.T) { + log, err := Open("", nil) + if err != nil { + t.Fatal(err) + } + if err := log.Write(Entry{Action: "scan"}); err != nil { + t.Errorf("no-op Write should not error: %v", err) + } + if err := log.Close(); err != nil { + t.Errorf("no-op Close should not error: %v", err) + } +} diff --git a/internal/discover/aws.go b/internal/discover/aws.go new file mode 100644 index 0000000..00dcebf --- /dev/null +++ b/internal/discover/aws.go @@ -0,0 +1,103 @@ +package discover + +import ( + "bufio" + "context" + "os" + "path/filepath" + "strings" + + "incredigo/internal/vault" +) + +func init() { Register(&awsScanner{}) } + +// awsScanner reads ~/.aws/credentials (INI). Read-only: opens O_RDONLY and never +// writes back. +type awsScanner struct{} + +func (a *awsScanner) Name() string { return "aws" } + +func (a *awsScanner) path() string { + if p := os.Getenv("AWS_SHARED_CREDENTIALS_FILE"); p != "" { + return p + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".aws", "credentials") +} + +func (a *awsScanner) Available() bool { + _, err := os.Stat(a.path()) + return err == nil +} + +func (a *awsScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) { + p := a.path() + f, err := os.Open(p) // read-only + if err != nil { + return nil, err + } + defer f.Close() + + fi, _ := f.Stat() + + var creds []Credential + var profile, keyID string + + flush := func(secret string) { + if profile == "" || secret == "" { + return + } + creds = append(creds, Credential{ + Source: a.Name(), + Kind: KindAWSKey, + Identity: profile + " / " + redactMiddle(keyID), + Location: p, + Modified: fi.ModTime(), + Secret: v.Store([]byte(secret)), + Meta: map[string]string{"access_key_id": redactMiddle(keyID)}, + }) + keyID = "" + } + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + profile = strings.TrimSpace(line[1 : len(line)-1]) + keyID = "" + continue + } + k, val, ok := splitKV(line) + if !ok { + continue + } + switch strings.ToLower(k) { + case "aws_access_key_id": + keyID = val + case "aws_secret_access_key": + flush(val) + } + } + return creds, sc.Err() +} + +func splitKV(line string) (k, v string, ok bool) { + i := strings.IndexByte(line, '=') + if i < 0 { + return "", "", false + } + return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]), true +} + +// redactMiddle keeps the first 4 and last 2 chars, masking the middle, so labels +// are human-recognizable without leaking the value. +func redactMiddle(s string) string { + if len(s) <= 8 { + return strings.Repeat("*", len(s)) + } + return s[:4] + strings.Repeat("*", len(s)-6) + s[len(s)-2:] +} diff --git a/internal/discover/discover.go b/internal/discover/discover.go new file mode 100644 index 0000000..c293ba9 --- /dev/null +++ b/internal/discover/discover.go @@ -0,0 +1,101 @@ +// Package discover holds the read-only credential scanners and their registry. +// +// Each scanner understands exactly one location/format, never modifies the files +// it reads, and emits Credential records whose secret value is a handle into the +// RAM vault — never a plaintext copy. +package discover + +import ( + "context" + "sort" + "sync" + "time" + + "incredigo/internal/vault" +) + +// Kind classifies a credential for policy and pathing purposes. +type Kind string + +const ( + KindAWSKey Kind = "aws_key" + KindToken Kind = "token" + KindPassword Kind = "password" + KindPrivateKey Kind = "private_key" +) + +// Credential is a normalized, non-secret-bearing record. The actual secret is +// referenced by Secret (a vault handle); nothing here should ever be a plaintext +// secret. Identity must be pre-redacted (e.g. "default / AKIA…REDACTED"). +type Credential struct { + Source string // scanner name, e.g. "aws" + Kind Kind // classification + Identity string // non-secret label + Location string // file the cred came from + Modified time.Time // source mtime — input to expiry policy + Secret *vault.Handle // opaque handle into the RAM vault + Meta map[string]string // non-secret extras +} + +// Scanner is the single interface a source must implement. Add a source by +// dropping one new file in this package that Registers a Scanner in init(). +type Scanner interface { + Name() string + // Available reports whether this source exists on the current machine, so we + // can skip cleanly rather than erroring on absent files. + Available() bool + // Scan reads the source read-only and stores any secrets in v, returning + // metadata records that reference them. + Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) +} + +var ( + regMu sync.Mutex + registry = map[string]Scanner{} +) + +// Register adds a scanner to the global registry. Call from a scanner's init(). +func Register(s Scanner) { + regMu.Lock() + defer regMu.Unlock() + registry[s.Name()] = s +} + +// Scanners returns all registered scanners sorted by name. If names is non-empty, +// only those are returned (unknown names are silently ignored by the caller's +// filtering; use Lookup if you need to detect unknown names). +func Scanners(names ...string) []Scanner { + regMu.Lock() + defer regMu.Unlock() + + var out []Scanner + if len(names) == 0 { + for _, s := range registry { + out = append(out, s) + } + } else { + for _, n := range names { + if s, ok := registry[n]; ok { + out = append(out, s) + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() }) + return out +} + +// ScanAll runs the selected (or all) available scanners and aggregates results. +func ScanAll(ctx context.Context, v *vault.Vault, names ...string) ([]Credential, error) { + var all []Credential + for _, s := range Scanners(names...) { + if !s.Available() { + continue + } + creds, err := s.Scan(ctx, v) + if err != nil { + return all, err + } + all = append(all, creds...) + } + return all, nil +} diff --git a/internal/discover/docker.go b/internal/discover/docker.go new file mode 100644 index 0000000..c94764b --- /dev/null +++ b/internal/discover/docker.go @@ -0,0 +1,78 @@ +package discover + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + + "incredigo/internal/vault" +) + +func init() { Register(&dockerScanner{}) } + +// dockerScanner reads ~/.docker/config.json. Each auths entry holds a base64 +// user:pass in "auth" (or an "identitytoken"). Read-only. +type dockerScanner struct{} + +func (d *dockerScanner) Name() string { return "docker" } + +func (d *dockerScanner) path() string { + if p := os.Getenv("DOCKER_CONFIG"); p != "" { + return filepath.Join(p, "config.json") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".docker", "config.json") +} + +func (d *dockerScanner) Available() bool { + _, err := os.Stat(d.path()) + return err == nil +} + +func (d *dockerScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) { + p := d.path() + b, err := os.ReadFile(p) // read-only + if err != nil { + return nil, err + } + fi, _ := os.Stat(p) + + var cfg struct { + Auths map[string]struct { + Auth string `json:"auth"` + IdentityToken string `json:"identitytoken"` + } `json:"auths"` + } + if err := json.Unmarshal(b, &cfg); err != nil { + return nil, err + } + + registries := make([]string, 0, len(cfg.Auths)) + for r := range cfg.Auths { + registries = append(registries, r) + } + sort.Strings(registries) // deterministic order + + var creds []Credential + for _, registry := range registries { + a := cfg.Auths[registry] + secret, kind := a.Auth, KindPassword + if secret == "" && a.IdentityToken != "" { + secret, kind = a.IdentityToken, KindToken + } + if secret == "" { + continue + } + creds = append(creds, Credential{ + Source: d.Name(), + Kind: kind, + Identity: registry, + Location: p, + Modified: fi.ModTime(), + Secret: v.Store([]byte(secret)), + }) + } + return creds, nil +} diff --git a/internal/discover/env.go b/internal/discover/env.go new file mode 100644 index 0000000..0948b69 --- /dev/null +++ b/internal/discover/env.go @@ -0,0 +1,105 @@ +package discover + +import ( + "bufio" + "context" + "math" + "os" + "path/filepath" + "strings" + + "incredigo/internal/vault" +) + +func init() { Register(&envScanner{}) } + +// envScanner reads .env files in the current working directory. It keeps only +// values that look secret-ish: the key name matches a sensitive pattern OR the +// value has high Shannon entropy. This avoids hoovering up PORT=8080 etc. +type envScanner struct{} + +func (e *envScanner) Name() string { return "env" } + +func (e *envScanner) files() []string { + matches, _ := filepath.Glob(".env") + more, _ := filepath.Glob(".env.*") + return append(matches, more...) +} + +func (e *envScanner) Available() bool { return len(e.files()) > 0 } + +var sensitiveHints = []string{ + "secret", "token", "passwd", "password", "apikey", "api_key", + "private", "key", "credential", "auth", "access", +} + +func (e *envScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) { + var creds []Credential + for _, p := range e.files() { + f, err := os.Open(p) // read-only + if err != nil { + return creds, err + } + fi, _ := f.Stat() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimPrefix(line, "export ") + k, val, ok := splitKV(line) + if !ok || val == "" { + continue + } + val = strings.Trim(val, `"'`) + if !looksSecret(k, val) { + continue + } + creds = append(creds, Credential{ + Source: e.Name(), + Kind: KindToken, + Identity: filepath.Base(p) + " / " + k, + Location: p, + Modified: fi.ModTime(), + Secret: v.Store([]byte(val)), + }) + } + f.Close() + if err := sc.Err(); err != nil { + return creds, err + } + } + return creds, nil +} + +func looksSecret(key, val string) bool { + lk := strings.ToLower(key) + for _, h := range sensitiveHints { + if strings.Contains(lk, h) { + return true + } + } + // Fallback: long, high-entropy values are probably secrets. + return len(val) >= 16 && shannonEntropy(val) >= 3.5 +} + +func shannonEntropy(s string) float64 { + if s == "" { + return 0 + } + var freq [256]float64 + for i := 0; i < len(s); i++ { + freq[s[i]]++ + } + n := float64(len(s)) + var h float64 + for _, c := range freq { + if c == 0 { + continue + } + p := c / n + h -= p * math.Log2(p) + } + return h +} diff --git a/internal/discover/file.go b/internal/discover/file.go new file mode 100644 index 0000000..51b4d88 --- /dev/null +++ b/internal/discover/file.go @@ -0,0 +1,120 @@ +package discover + +import ( + "bytes" + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + + "incredigo/internal/vault" +) + +// maxHarvestFileSize bounds how large a file the "file" scanner will slurp as a +// single credential. Credential material (keys, tokens, service-account JSON) is +// small; this keeps an accidental directory of large blobs from being vaulted. +const maxHarvestFileSize = 1 << 20 // 1 MiB + +var fileSingleton = &fileScanner{} + +func init() { Register(fileSingleton) } + +// AddPath registers an extra file or directory for the "file" scanner to harvest. +// Call before ScanAll — the CLI does this for each --path. A directory is walked +// recursively; a single file is harvested directly. +func AddPath(p string) { + if p != "" { + fileSingleton.targets = append(fileSingleton.targets, p) + } +} + +// fileScanner harvests whole files as credentials from user-supplied paths. Unlike +// the format-specific scanners (aws, env, …), it does no field parsing: each +// regular, non-empty, reasonably-sized file becomes ONE Credential whose secret is +// the file's entire contents — the natural fit for "point me at a directory of +// credential files and take them all." Strictly read-only. +type fileScanner struct{ targets []string } + +func (f *fileScanner) Name() string { return "file" } + +func (f *fileScanner) Available() bool { return len(f.targets) > 0 } + +func (f *fileScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) { + var creds []Credential + for _, t := range f.targets { + info, err := os.Stat(t) + if err != nil { + return creds, fmt.Errorf("file scanner: %q: %w", t, err) + } + if !info.IsDir() { + if c, ok := f.harvest(v, t, filepath.Dir(t)); ok { + creds = append(creds, c) + } + continue + } + // Directory: walk recursively, harvesting each regular file. Per-entry + // errors (unreadable, vanished) are skipped so one bad file doesn't abort + // the whole harvest. + walkErr := filepath.WalkDir(t, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() || !d.Type().IsRegular() { + return nil + } + if c, ok := f.harvest(v, p, t); ok { + creds = append(creds, c) + } + return nil + }) + if walkErr != nil { + return creds, walkErr + } + } + return creds, nil +} + +// harvest reads one file and turns it into a Credential. root is the target the +// file was found under, used to build a stable relative Identity. Returns ok=false +// for empty, oversized, or unreadable files (silently skipped). +func (f *fileScanner) harvest(v *vault.Vault, path, root string) (Credential, bool) { + info, err := os.Stat(path) + if err != nil || info.Size() == 0 || info.Size() > maxHarvestFileSize { + return Credential{}, false + } + b, err := os.ReadFile(path) // read-only + if err != nil { + return Credential{}, false + } + + kind := classifyContent(b) // inspect bytes BEFORE Store wipes them + rel, e := filepath.Rel(root, path) + if e != nil { + rel = filepath.Base(path) + } + // Identity is the relative path (non-secret, never the contents). We do NOT + // prefix the scanner name here — StorePath already prepends the source + // segment, so a "file / " prefix would double it to imported/file/file/. + return Credential{ + Source: f.Name(), + Kind: kind, + Identity: rel, + Location: path, + Modified: info.ModTime(), + Secret: v.Store(b), // copies into locked mem, wipes b + }, true +} + +// classifyContent guesses a Kind from the file's leading bytes WITHOUT turning the +// secret into a Go string (it inspects the byte slice in place). +func classifyContent(b []byte) Kind { + head := b + if len(head) > 256 { + head = head[:256] + } + if bytes.Contains(head, []byte("PRIVATE KEY-----")) { + return KindPrivateKey + } + return KindToken +} diff --git a/internal/discover/git.go b/internal/discover/git.go new file mode 100644 index 0000000..900398b --- /dev/null +++ b/internal/discover/git.go @@ -0,0 +1,71 @@ +package discover + +import ( + "bufio" + "context" + "net/url" + "os" + "path/filepath" + "strings" + + "incredigo/internal/vault" +) + +func init() { Register(&gitScanner{}) } + +// gitScanner reads ~/.git-credentials, whose lines are URLs with embedded +// credentials, e.g. https://user:ghp_xxx@github.com. The userinfo password is the +// secret. Read-only. +type gitScanner struct{} + +func (g *gitScanner) Name() string { return "git" } + +func (g *gitScanner) path() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".git-credentials") +} + +func (g *gitScanner) Available() bool { + _, err := os.Stat(g.path()) + return err == nil +} + +func (g *gitScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) { + p := g.path() + f, err := os.Open(p) // read-only + if err != nil { + return nil, err + } + defer f.Close() + fi, _ := f.Stat() + + var creds []Credential + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + u, err := url.Parse(line) + if err != nil || u.User == nil { + continue + } + pass, ok := u.User.Password() + if !ok || pass == "" { + continue + } + id := u.Host + if user := u.User.Username(); user != "" { + id = user + " @ " + u.Host + } + creds = append(creds, Credential{ + Source: g.Name(), + Kind: KindToken, + Identity: id, + Location: p, + Modified: fi.ModTime(), + Secret: v.Store([]byte(pass)), + }) + } + return creds, sc.Err() +} diff --git a/internal/discover/kube.go b/internal/discover/kube.go new file mode 100644 index 0000000..a200027 --- /dev/null +++ b/internal/discover/kube.go @@ -0,0 +1,84 @@ +package discover + +import ( + "context" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "incredigo/internal/vault" +) + +func init() { Register(&kubeScanner{}) } + +// kubeScanner reads ~/.kube/config (YAML) and emits a credential per user that +// carries a static secret: a bearer token, a client key, or a basic-auth +// password. Dynamic exec/auth-provider users (no embedded secret) are skipped. +// Read-only. +type kubeScanner struct{} + +func (k *kubeScanner) Name() string { return "kube" } + +func (k *kubeScanner) path() string { + if p := os.Getenv("KUBECONFIG"); p != "" { + // KUBECONFIG may be a colon-separated list; use the first entry. + return strings.SplitN(p, ":", 2)[0] + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".kube", "config") +} + +func (k *kubeScanner) Available() bool { + _, err := os.Stat(k.path()) + return err == nil +} + +func (k *kubeScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) { + p := k.path() + b, err := os.ReadFile(p) // read-only + if err != nil { + return nil, err + } + fi, _ := os.Stat(p) + + var cfg struct { + Users []struct { + Name string `yaml:"name"` + User struct { + Token string `yaml:"token"` + Password string `yaml:"password"` + ClientKeyData string `yaml:"client-key-data"` + } `yaml:"user"` + } `yaml:"users"` + } + if err := yaml.Unmarshal(b, &cfg); err != nil { + return nil, err + } + + var creds []Credential + for _, u := range cfg.Users { + var secret string + kind := KindToken + switch { + case u.User.Token != "": + secret, kind = u.User.Token, KindToken + case u.User.ClientKeyData != "": + secret, kind = u.User.ClientKeyData, KindPrivateKey + case u.User.Password != "": + secret, kind = u.User.Password, KindPassword + default: + continue // dynamic credential (exec/auth-provider) — nothing static to take + } + creds = append(creds, Credential{ + Source: k.Name(), + Kind: kind, + Identity: u.Name, + Location: p, + Modified: fi.ModTime(), + Secret: v.Store([]byte(secret)), + }) + } + return creds, nil +} diff --git a/internal/discover/netrc.go b/internal/discover/netrc.go new file mode 100644 index 0000000..47b4f3c --- /dev/null +++ b/internal/discover/netrc.go @@ -0,0 +1,109 @@ +package discover + +import ( + "bufio" + "context" + "io" + "os" + "path/filepath" + + "incredigo/internal/vault" +) + +func init() { Register(&netrcScanner{}) } + +// netrcScanner reads ~/.netrc and ~/.authinfo (whitespace-delimited +// machine/login/password tokens). Read-only. +type netrcScanner struct{} + +func (n *netrcScanner) Name() string { return "netrc" } + +func (n *netrcScanner) paths() []string { + home, _ := os.UserHomeDir() + return []string{ + filepath.Join(home, ".netrc"), + filepath.Join(home, ".authinfo"), + } +} + +func (n *netrcScanner) Available() bool { + for _, p := range n.paths() { + if _, err := os.Stat(p); err == nil { + return true + } + } + return false +} + +func (n *netrcScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) { + var creds []Credential + for _, p := range n.paths() { + f, err := os.Open(p) // read-only + if err != nil { + continue // a missing alternate file is fine + } + fi, _ := f.Stat() + toks := tokenizeWords(f) + f.Close() + + var machine, login, password string + flush := func() { + if machine != "" && password != "" { + id := machine + if login != "" { + id = login + " @ " + machine + } + creds = append(creds, Credential{ + Source: n.Name(), + Kind: KindPassword, + Identity: id, + Location: p, + Modified: fi.ModTime(), + Secret: v.Store([]byte(password)), + }) + } + machine, login, password = "", "", "" + } + + for i := 0; i < len(toks); i++ { + switch toks[i] { + case "machine": + flush() + if i+1 < len(toks) { + machine = toks[i+1] + i++ + } + case "default": + flush() + machine = "default" + case "login": + if i+1 < len(toks) { + login = toks[i+1] + i++ + } + case "password": + if i+1 < len(toks) { + password = toks[i+1] + i++ + } + case "account", "macdef": + if i+1 < len(toks) { // skip the value token + i++ + } + } + } + flush() + } + return creds, nil +} + +// tokenizeWords splits an io.Reader into whitespace-delimited tokens. +func tokenizeWords(r io.Reader) []string { + sc := bufio.NewScanner(r) + sc.Split(bufio.ScanWords) + var t []string + for sc.Scan() { + t = append(t, sc.Text()) + } + return t +} diff --git a/internal/discover/scanners_test.go b/internal/discover/scanners_test.go new file mode 100644 index 0000000..80e94e4 --- /dev/null +++ b/internal/discover/scanners_test.go @@ -0,0 +1,287 @@ +package discover + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "incredigo/internal/vault" +) + +func writeFixture(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +// assertNoLeak fails if any secret substring appears in a non-secret field +// (Identity, Location, Source, Kind, or any Meta key/value). +func assertNoLeak(t *testing.T, creds []Credential, secrets ...string) { + t.Helper() + for _, c := range creds { + fields := []string{c.Identity, c.Source, c.Location, string(c.Kind)} + for k, val := range c.Meta { + fields = append(fields, k, val) + } + for _, f := range fields { + for _, s := range secrets { + if s != "" && strings.Contains(f, s) { + t.Errorf("secret %q leaked into non-secret field %q", s, f) + } + } + } + } +} + +func openSecret(t *testing.T, v *vault.Vault, c Credential) string { + t.Helper() + buf, err := v.Open(c.Secret) + if err != nil { + t.Fatalf("open vaulted secret: %v", err) + } + return string(buf.Bytes()) +} + +// byIdentity indexes creds by Identity for assertions. +func byIdentity(creds []Credential) map[string]Credential { + m := make(map[string]Credential, len(creds)) + for _, c := range creds { + m[c.Identity] = c + } + return m +} + +func TestAWSScanner(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "credentials") + writeFixture(t, p, "[default]\naws_access_key_id = AKIAIOSFODNN7EXAMPLE\naws_secret_access_key = wJalrSECRETkeyMaterial0123\n") + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", p) + + v := vault.New() + defer v.Purge() + creds, err := (&awsScanner{}).Scan(context.Background(), v) + if err != nil { + t.Fatal(err) + } + if len(creds) != 1 { + t.Fatalf("want 1 cred, got %d", len(creds)) + } + c := creds[0] + if c.Kind != KindAWSKey { + t.Errorf("kind = %s, want aws_key", c.Kind) + } + if !strings.Contains(c.Identity, "default") { + t.Errorf("identity %q missing profile", c.Identity) + } + if got := openSecret(t, v, c); got != "wJalrSECRETkeyMaterial0123" { + t.Errorf("secret round-trip = %q", got) + } + assertNoLeak(t, creds, "wJalrSECRETkeyMaterial0123", "AKIAIOSFODNN7EXAMPLE") +} + +func TestEnvScanner(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeFixture(t, filepath.Join(dir, ".env"), + "PORT=8080\nDEBUG=true\nSTRIPE_API_KEY=sk_live_envSECRET0123456789\nexport DB_PASSWORD=\"pw-envSECRET-xyz\"\n") + + v := vault.New() + defer v.Purge() + creds, err := (&envScanner{}).Scan(context.Background(), v) + if err != nil { + t.Fatal(err) + } + if len(creds) != 2 { + t.Fatalf("want 2 creds (secret-ish only), got %d: %+v", len(creds), creds) + } + idents := byIdentity(creds) + if _, ok := idents[".env / STRIPE_API_KEY"]; !ok { + t.Errorf("missing STRIPE_API_KEY; got %v", idents) + } + for id := range idents { + if strings.Contains(id, "PORT") || strings.Contains(id, "DEBUG") { + t.Errorf("non-secret %q should have been filtered out", id) + } + } + assertNoLeak(t, creds, "sk_live_envSECRET0123456789", "pw-envSECRET-xyz") +} + +func TestNetrcScanner(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeFixture(t, filepath.Join(home, ".netrc"), + "machine api.example.com login alice password netrcSECRETaaa\ndefault login bob password netrcDEFAULTbbb\n") + + v := vault.New() + defer v.Purge() + creds, err := (&netrcScanner{}).Scan(context.Background(), v) + if err != nil { + t.Fatal(err) + } + if len(creds) != 2 { + t.Fatalf("want 2 creds, got %d", len(creds)) + } + idents := byIdentity(creds) + if c, ok := idents["alice @ api.example.com"]; !ok { + t.Errorf("missing machine entry; got %v", idents) + } else if got := openSecret(t, v, c); got != "netrcSECRETaaa" { + t.Errorf("secret = %q", got) + } + if _, ok := idents["bob @ default"]; !ok { + t.Errorf("missing default entry; got %v", idents) + } + assertNoLeak(t, creds, "netrcSECRETaaa", "netrcDEFAULTbbb") +} + +func TestDockerScanner(t *testing.T) { + dir := t.TempDir() + t.Setenv("DOCKER_CONFIG", dir) + writeFixture(t, filepath.Join(dir, "config.json"), + `{"auths":{"registry.example.com":{"auth":"ZG9ja2VyU0VDUkVUZWVl"},"ghcr.io":{"identitytoken":"dockerTOKENfff"}}}`) + + v := vault.New() + defer v.Purge() + creds, err := (&dockerScanner{}).Scan(context.Background(), v) + if err != nil { + t.Fatal(err) + } + if len(creds) != 2 { + t.Fatalf("want 2 creds, got %d", len(creds)) + } + idents := byIdentity(creds) + if c, ok := idents["registry.example.com"]; !ok || c.Kind != KindPassword { + t.Errorf("registry entry wrong: %+v", c) + } + if c, ok := idents["ghcr.io"]; !ok || c.Kind != KindToken { + t.Errorf("ghcr entry wrong: %+v", c) + } + assertNoLeak(t, creds, "ZG9ja2VyU0VDUkVUZWVl", "dockerTOKENfff") +} + +func TestKubeScanner(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config") + t.Setenv("KUBECONFIG", p) + writeFixture(t, p, `apiVersion: v1 +users: +- name: admin + user: + token: kubeSECRETggg +- name: certuser + user: + client-key-data: a3ViZUtFWWhoaA== +- name: execuser + user: + exec: + command: aws +`) + + v := vault.New() + defer v.Purge() + creds, err := (&kubeScanner{}).Scan(context.Background(), v) + if err != nil { + t.Fatal(err) + } + if len(creds) != 2 { + t.Fatalf("want 2 creds (exec user skipped), got %d", len(creds)) + } + idents := byIdentity(creds) + if c, ok := idents["admin"]; !ok || c.Kind != KindToken { + t.Errorf("admin entry wrong: %+v", c) + } + if c, ok := idents["certuser"]; !ok || c.Kind != KindPrivateKey { + t.Errorf("certuser entry wrong: %+v", c) + } + if _, ok := idents["execuser"]; ok { + t.Error("execuser (dynamic) should be skipped") + } + assertNoLeak(t, creds, "kubeSECRETggg", "a3ViZUtFWWhoaA==") +} + +func TestGitScanner(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeFixture(t, filepath.Join(home, ".git-credentials"), + "https://alice:gitSECRETiii@github.com\nhttps://gitlab.com\n") + + v := vault.New() + defer v.Purge() + creds, err := (&gitScanner{}).Scan(context.Background(), v) + if err != nil { + t.Fatal(err) + } + if len(creds) != 1 { + t.Fatalf("want 1 cred (no-cred line skipped), got %d", len(creds)) + } + c := creds[0] + if c.Identity != "alice @ github.com" { + t.Errorf("identity = %q", c.Identity) + } + if got := openSecret(t, v, c); got != "gitSECRETiii" { + t.Errorf("secret = %q", got) + } + assertNoLeak(t, creds, "gitSECRETiii") +} + +func TestSSHScanner(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeFixture(t, filepath.Join(home, ".ssh", "id_ed25519"), + "-----BEGIN OPENSSH PRIVATE KEY-----\nsshSECRETkeyccc\n-----END OPENSSH PRIVATE KEY-----\n") + writeFixture(t, filepath.Join(home, ".ssh", "id_ed25519.pub"), "ssh-ed25519 AAAA pub") + writeFixture(t, filepath.Join(home, ".ssh", "id_rsa_enc"), + "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nsshSECRETencddd\n-----END RSA PRIVATE KEY-----\n") + + v := vault.New() + defer v.Purge() + creds, err := (&sshScanner{}).Scan(context.Background(), v) + if err != nil { + t.Fatal(err) + } + if len(creds) != 2 { + t.Fatalf("want 2 private keys (.pub ignored), got %d: %+v", len(creds), creds) + } + idents := byIdentity(creds) + if c, ok := idents["id_rsa_enc"]; !ok || c.Meta["encrypted"] != "true" { + t.Errorf("encrypted key not flagged: %+v", c) + } + if c, ok := idents["id_ed25519"]; !ok || c.Meta["encrypted"] != "false" { + t.Errorf("plaintext key wrongly flagged: %+v", c) + } + assertNoLeak(t, creds, "sshSECRETkeyccc", "sshSECRETencddd") +} + +func TestFileScanner(t *testing.T) { + root := t.TempDir() + writeFixture(t, filepath.Join(root, "github-token"), "fileSECRETtok") + writeFixture(t, filepath.Join(root, "keys", "deploy.pem"), + "-----BEGIN OPENSSH PRIVATE KEY-----\nfileKEYmaterial\n-----END OPENSSH PRIVATE KEY-----\n") + writeFixture(t, filepath.Join(root, "empty"), "") + + v := vault.New() + defer v.Purge() + fs := &fileScanner{targets: []string{root}} + creds, err := fs.Scan(context.Background(), v) + if err != nil { + t.Fatal(err) + } + if len(creds) != 2 { + t.Fatalf("want 2 creds (empty skipped), got %d: %+v", len(creds), creds) + } + idents := byIdentity(creds) + if c, ok := idents["github-token"]; !ok { + t.Errorf("missing github-token; got %v", idents) + } else if got := openSecret(t, v, c); got != "fileSECRETtok" { + t.Errorf("secret = %q", got) + } + if c, ok := idents[filepath.Join("keys", "deploy.pem")]; !ok || c.Kind != KindPrivateKey { + t.Errorf("pem not classified as private_key: %+v", c) + } + assertNoLeak(t, creds, "fileSECRETtok", "fileKEYmaterial") +} diff --git a/internal/discover/ssh.go b/internal/discover/ssh.go new file mode 100644 index 0000000..c883072 --- /dev/null +++ b/internal/discover/ssh.go @@ -0,0 +1,67 @@ +package discover + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strconv" + "strings" + + "incredigo/internal/vault" +) + +func init() { Register(&sshScanner{}) } + +// sshScanner harvests private keys from ~/.ssh (files named id_* that aren't +// .pub). The whole key file is the secret. Read-only. +type sshScanner struct{} + +func (s *sshScanner) Name() string { return "ssh" } + +func (s *sshScanner) dir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".ssh") +} + +func (s *sshScanner) Available() bool { + fi, err := os.Stat(s.dir()) + return err == nil && fi.IsDir() +} + +func (s *sshScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) { + entries, err := os.ReadDir(s.dir()) + if err != nil { + return nil, err + } + var creds []Credential + for _, e := range entries { + name := e.Name() + if !e.Type().IsRegular() || !strings.HasPrefix(name, "id_") || strings.HasSuffix(name, ".pub") { + continue + } + p := filepath.Join(s.dir(), name) + b, err := os.ReadFile(p) // read-only + if err != nil { + continue + } + if !bytes.Contains(b, []byte("PRIVATE KEY")) { + continue // not a private key file + } + fi, _ := os.Stat(p) + // Best-effort: PEM-encrypted keys carry an explicit marker. (OpenSSH-format + // encryption isn't detectable without parsing the body, so this can + // under-report; it never claims a plaintext key is encrypted.) + encrypted := bytes.Contains(b, []byte("ENCRYPTED")) + creds = append(creds, Credential{ + Source: s.Name(), + Kind: KindPrivateKey, + Identity: name, + Location: p, + Modified: fi.ModTime(), + Secret: v.Store(b), + Meta: map[string]string{"encrypted": strconv.FormatBool(encrypted)}, + }) + } + return creds, nil +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go new file mode 100644 index 0000000..6f6ec12 --- /dev/null +++ b/internal/policy/policy.go @@ -0,0 +1,119 @@ +// Package policy is the local-only expiry engine. It reads credential mtimes and +// reports age against thresholds. No network calls in v1. +package policy + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "incredigo/internal/discover" +) + +type State string + +const ( + OK State = "ok" + Warn State = "warn" + Stale State = "stale" +) + +// Thresholds in days. Zero means "unset / no opinion". +type Threshold struct { + WarnAfter Days `yaml:"warn_after"` + StaleAfter Days `yaml:"stale_after"` +} + +type Policy struct { + Defaults Threshold `yaml:"defaults"` + Overrides map[discover.Kind]Threshold `yaml:"overrides"` +} + +// Default policy used when no config file is present. +func Default() Policy { + return Policy{ + Defaults: Threshold{WarnAfter: 60, StaleAfter: 90}, + Overrides: map[discover.Kind]Threshold{ + discover.KindAWSKey: {WarnAfter: 30, StaleAfter: 90}, + discover.KindPrivateKey: {WarnAfter: 180}, + }, + } +} + +// Load reads a YAML policy file, falling back to Default if path is empty or +// missing. +func Load(path string) (Policy, error) { + if path == "" { + return Default(), nil + } + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return Default(), nil + } + if err != nil { + return Policy{}, err + } + p := Default() + if err := yaml.Unmarshal(b, &p); err != nil { + return Policy{}, err + } + return p, nil +} + +// Evaluate returns the state and age for one credential. +func (p Policy) Evaluate(c discover.Credential, now time.Time) (State, time.Duration) { + age := now.Sub(c.Modified) + t := p.Defaults + if ov, ok := p.Overrides[c.Kind]; ok { + if ov.WarnAfter != 0 { + t.WarnAfter = ov.WarnAfter + } + if ov.StaleAfter != 0 { + t.StaleAfter = ov.StaleAfter + } + } + switch { + case t.StaleAfter != 0 && age >= t.StaleAfter.Duration(): + return Stale, age + case t.WarnAfter != 0 && age >= t.WarnAfter.Duration(): + return Warn, age + default: + return OK, age + } +} + +// Days is a day-count threshold. In policy.yaml it may be written either as a +// bare integer (`60`) or with a unit suffix: `60d` (days) or `8w` (weeks). Zero +// means "unset / no opinion". +type Days int + +func (d Days) Duration() time.Duration { return time.Duration(d) * 24 * time.Hour } + +// UnmarshalYAML accepts a scalar that is either an integer count of days or a +// string with a `d`/`w` unit suffix, so the DESIGN's `warn_after: 60d` form and a +// plain `warn_after: 60` both work. +func (d *Days) UnmarshalYAML(node *yaml.Node) error { + s := strings.TrimSpace(node.Value) + if s == "" { + *d = 0 + return nil + } + mult := 1 + switch s[len(s)-1] { + case 'd', 'D': + s = strings.TrimSpace(s[:len(s)-1]) + case 'w', 'W': + mult = 7 + s = strings.TrimSpace(s[:len(s)-1]) + } + n, err := strconv.Atoi(s) + if err != nil { + return fmt.Errorf("policy: invalid day threshold %q (want e.g. 60, 60d, or 8w)", node.Value) + } + *d = Days(n * mult) + return nil +} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go new file mode 100644 index 0000000..3b46abd --- /dev/null +++ b/internal/policy/policy_test.go @@ -0,0 +1,82 @@ +package policy + +import ( + "testing" + "time" + + "gopkg.in/yaml.v3" + + "incredigo/internal/discover" +) + +func TestDaysUnmarshal(t *testing.T) { + ok := map[string]Days{ + "60": 60, + "60d": 60, + "8w": 56, + " 90d ": 90, + "0": 0, + } + for in, want := range ok { + var d Days + if err := yaml.Unmarshal([]byte(in), &d); err != nil { + t.Errorf("%q: unexpected error %v", in, err) + continue + } + if d != want { + t.Errorf("%q: got %d want %d", in, d, want) + } + } + for _, bad := range []string{"60x", "abc", "d"} { + var d Days + if err := yaml.Unmarshal([]byte(bad), &d); err == nil { + t.Errorf("%q: expected error, got %d", bad, d) + } + } +} + +func TestPolicyYAMLWithSuffixes(t *testing.T) { + src := ` +defaults: + warn_after: 60d + stale_after: 90d +overrides: + private_key: + warn_after: 26w +` + p := Default() + if err := yaml.Unmarshal([]byte(src), &p); err != nil { + t.Fatal(err) + } + if p.Defaults.WarnAfter != 60 || p.Defaults.StaleAfter != 90 { + t.Errorf("defaults = %+v", p.Defaults) + } + if p.Overrides[discover.KindPrivateKey].WarnAfter != 182 { // 26*7 + t.Errorf("private_key warn_after = %d, want 182", p.Overrides[discover.KindPrivateKey].WarnAfter) + } +} + +func TestEvaluate(t *testing.T) { + p := Default() // defaults 60/90; aws_key 30/90 + now := time.Now() + aged := func(days int, kind discover.Kind) discover.Credential { + return discover.Credential{Kind: kind, Modified: now.Add(-time.Duration(days) * 24 * time.Hour)} + } + + cases := []struct { + name string + cred discover.Credential + want State + }{ + {"fresh token", aged(10, discover.KindToken), OK}, + {"warn token", aged(70, discover.KindToken), Warn}, + {"stale token", aged(100, discover.KindToken), Stale}, + {"aws warns earlier", aged(40, discover.KindAWSKey), Warn}, + {"aws fresh", aged(20, discover.KindAWSKey), OK}, + } + for _, c := range cases { + if st, _ := p.Evaluate(c.cred, now); st != c.want { + t.Errorf("%s: got %s want %s", c.name, st, c.want) + } + } +} diff --git a/internal/sink/age.go b/internal/sink/age.go new file mode 100644 index 0000000..d434747 --- /dev/null +++ b/internal/sink/age.go @@ -0,0 +1,68 @@ +package sink + +import ( + "context" + "fmt" + "io" + + "filippo.io/age" + "github.com/awnumar/memguard" +) + +// Age is the default Sealer. It wraps the bundle in the age format +// (ChaCha20-Poly1305 payload, scrypt-derived key from the passphrase), which is +// authenticated: a corrupted or tampered backup fails to open rather than +// decrypting to silent garbage. A bundle can also be restored by hand with the +// standard `age` CLI: `age -d backup.incredigo.age`. +// +// Passphrase caveat: age's NewScrypt{Recipient,Identity} take a Go string, so the +// passphrase transits an (immutable, un-zeroable) Go string for the duration of +// the operation. The stored secrets themselves never do — they stream through as +// bytes (see bundle.go). This is the one place incredigo's RAM-only guarantee is +// reduced to "the passphrase, not the vaulted secrets." +type Age struct { + // WorkFactor is the scrypt log2(N) cost. Default 18 (~256 MiB) — a deliberate + // brute-force speed bump for a passphrase-protected disaster-recovery bundle. + WorkFactor int +} + +func (a *Age) Name() string { return "age" } + +func (a *Age) workFactor() int { + if a.WorkFactor == 0 { + return 18 + } + return a.WorkFactor +} + +func (a *Age) Seal(ctx context.Context, plaintext io.Reader, out io.Writer, pass *memguard.LockedBuffer) error { + r, err := age.NewScryptRecipient(string(pass.Bytes())) + if err != nil { + return fmt.Errorf("age: recipient: %w", err) + } + r.SetWorkFactor(a.workFactor()) + w, err := age.Encrypt(out, r) + if err != nil { + return fmt.Errorf("age: encrypt: %w", err) + } + if _, err := io.Copy(w, plaintext); err != nil { + _ = w.Close() + return fmt.Errorf("age: write: %w", err) + } + return w.Close() +} + +func (a *Age) Open(ctx context.Context, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error { + id, err := age.NewScryptIdentity(string(pass.Bytes())) + if err != nil { + return fmt.Errorf("age: identity: %w", err) + } + r, err := age.Decrypt(in, id) + if err != nil { + return fmt.Errorf("age: decrypt (wrong passphrase or corrupt bundle?): %w", err) + } + if _, err := io.Copy(out, r); err != nil { + return fmt.Errorf("age: read: %w", err) + } + return nil +} diff --git a/internal/sink/bundle.go b/internal/sink/bundle.go new file mode 100644 index 0000000..9265472 --- /dev/null +++ b/internal/sink/bundle.go @@ -0,0 +1,192 @@ +package sink + +// Bundle framing — the PLAINTEXT side of the Sealer. +// +// A bundle is a flat stream of records, each one gopass entry. It is produced by +// ExportTo and consumed by ImportFrom; in between it only ever exists encrypted +// (the Sealer wraps it) or in-flight through an io.Pipe. The wire format is: +// +// record* end +// record := uint16 pathLen // big-endian; pathLen==0 is the end sentinel +// [pathLen]byte path // non-secret gopass path, UTF-8 +// chunk* secretEnd +// chunk := uint32 chunkLen // big-endian; chunkLen==0 ends this secret +// [chunkLen]byte chunk // a slice of the secret value +// end := uint16 0 // zero-length path terminates the stream +// +// Secrets are streamed in chunks so a complete secret value is never assembled in +// one buffer and never becomes a Go string: bytes flow gopass.stdout -> frame -> +// pipe (export), and pipe -> frame -> gopass.stdin (import). Paths are not secret. + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "os/exec" + "strings" +) + +const exportChunk = 32 * 1024 + +// ListPaths returns the gopass entry paths under prefix, in gopass's order. +func (g *Gopass) ListPaths(ctx context.Context, prefix string) ([]string, error) { + out, err := exec.CommandContext(ctx, g.bin(), "ls", "--flat").Output() + if err != nil { + return nil, fmt.Errorf("gopass ls: %w", err) + } + var paths []string + for _, line := range strings.Split(string(out), "\n") { + p := strings.TrimSpace(line) + if p == "" { + continue + } + if prefix == "" || strings.HasPrefix(p, prefix) { + paths = append(paths, p) + } + } + return paths, nil +} + +// ExportTo writes the framed plaintext record stream for every entry under prefix +// to w. Each secret is streamed straight from `gopass show -o` into the frame; it +// is never buffered whole and never converted to a string. Returns the entry count. +func (g *Gopass) ExportTo(ctx context.Context, w io.Writer, prefix string) (int, error) { + paths, err := g.ListPaths(ctx, prefix) + if err != nil { + return 0, err + } + for _, p := range paths { + if err := writePath(w, p); err != nil { + return 0, err + } + if err := g.streamSecret(ctx, w, p); err != nil { + return 0, err + } + } + // end-of-stream sentinel: a zero-length path + if err := binary.Write(w, binary.BigEndian, uint16(0)); err != nil { + return 0, err + } + return len(paths), nil +} + +// streamSecret pipes one entry's secret value into w as a chunk sequence followed +// by a zero-length end marker. +func (g *Gopass) streamSecret(ctx context.Context, w io.Writer, path string) error { + cmd := exec.CommandContext(ctx, g.bin(), "show", "-o", path) + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + buf := make([]byte, exportChunk) + for { + n, rerr := stdout.Read(buf) + if n > 0 { + if err := binary.Write(w, binary.BigEndian, uint32(n)); err != nil { + _ = cmd.Wait() + return err + } + if _, err := w.Write(buf[:n]); err != nil { + _ = cmd.Wait() + return err + } + } + if rerr == io.EOF { + break + } + if rerr != nil { + _ = cmd.Wait() + return rerr + } + } + if err := cmd.Wait(); err != nil { + return fmt.Errorf("gopass show %q: %w", path, err) + } + // zero-length chunk ends this secret + return binary.Write(w, binary.BigEndian, uint32(0)) +} + +// ImportFrom reads a framed plaintext record stream from r and re-inserts every +// entry into gopass. Each secret streams frame -> gopass stdin without ever being +// held whole or stringified. force overwrites existing entries. Returns the count. +func (g *Gopass) ImportFrom(ctx context.Context, r io.Reader, force bool) (int, error) { + var n int + for { + var pathLen uint16 + if err := binary.Read(r, binary.BigEndian, &pathLen); err != nil { + if err == io.EOF { + return n, nil // tolerate missing sentinel at clean EOF + } + return n, err + } + if pathLen == 0 { + return n, nil // end-of-stream sentinel + } + pb := make([]byte, pathLen) + if _, err := io.ReadFull(r, pb); err != nil { + return n, err + } + if err := g.insertStream(ctx, r, string(pb), force); err != nil { + return n, err + } + n++ + } +} + +// insertStream spawns `gopass insert` for path and feeds it the chunked secret +// from r (consuming exactly up to this record's zero-length end marker). +func (g *Gopass) insertStream(ctx context.Context, r io.Reader, path string, force bool) error { + args := []string{"insert", "--multiline=false"} + if force { + args = append(args, "-f") + } + args = append(args, path) + + cmd := exec.CommandContext(ctx, g.bin(), args...) + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + + copyErr := copySecret(stdin, r) + stdin.Close() + if werr := cmd.Wait(); werr != nil { + return fmt.Errorf("gopass insert %q: %w", path, werr) + } + return copyErr +} + +// copySecret streams one secret's chunks from r to dst until the zero-length end +// marker. Uses io.CopyN so a full secret is never buffered. +func copySecret(dst io.Writer, r io.Reader) error { + for { + var chunkLen uint32 + if err := binary.Read(r, binary.BigEndian, &chunkLen); err != nil { + return err + } + if chunkLen == 0 { + return nil + } + if _, err := io.CopyN(dst, r, int64(chunkLen)); err != nil { + return err + } + } +} + +func writePath(w io.Writer, path string) error { + if len(path) > 0xffff { + return fmt.Errorf("gopass path too long: %d bytes", len(path)) + } + if err := binary.Write(w, binary.BigEndian, uint16(len(path))); err != nil { + return err + } + _, err := io.WriteString(w, path) + return err +} diff --git a/internal/sink/bundle_test.go b/internal/sink/bundle_test.go new file mode 100644 index 0000000..835d46a --- /dev/null +++ b/internal/sink/bundle_test.go @@ -0,0 +1,108 @@ +package sink + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "incredigo/internal/discover" +) + +// writeFakeGopass writes a stub `gopass` binary that stores entries as files under +// $FAKE_GOPASS_STORE, supporting the ls/show/insert subcommands the bundle code +// uses. This exercises the real framing + subprocess plumbing without touching the +// user's actual gopass store. +func writeFakeGopass(t *testing.T) string { + t.Helper() + bin := filepath.Join(t.TempDir(), "gopass") + script := `#!/usr/bin/env bash +store="$FAKE_GOPASS_STORE" +case "$1" in + ls) (cd "$store" 2>/dev/null && find . -type f | sed 's#^\./##' | sort) ;; + show) cat "$store/$3" ;; + insert) + shift; path="" + for a in "$@"; do case "$a" in --multiline=false|-f) ;; *) path="$a";; esac; done + mkdir -p "$store/$(dirname "$path")"; cat > "$store/$path" ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return bin +} + +func seed(t *testing.T, store string, entries map[string]string) { + t.Helper() + for path, secret := range entries { + full := filepath.Join(store, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(secret), 0o600); err != nil { + t.Fatal(err) + } + } +} + +func TestBundleRoundTrip(t *testing.T) { + bin := writeFakeGopass(t) + gp := &Gopass{Bin: bin} + ctx := context.Background() + + src := t.TempDir() + seed(t, src, map[string]string{ + "imported/aws/default": "AKIA/secret+with=specials", + "imported/env/api": "unicode-末-\U0001f511", + "other/skip": "should-not-export", + }) + + // Export the imported/ subtree to a framed (unsealed) buffer. + t.Setenv("FAKE_GOPASS_STORE", src) + var buf bytes.Buffer + n, err := gp.ExportTo(ctx, &buf, "imported/") + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("exported %d entries, want 2 (other/ excluded by prefix)", n) + } + + // Import into a fresh store and assert byte-exact restoration. + dst := t.TempDir() + t.Setenv("FAKE_GOPASS_STORE", dst) + m, err := gp.ImportFrom(ctx, &buf, true) + if err != nil { + t.Fatal(err) + } + if m != 2 { + t.Fatalf("imported %d entries, want 2", m) + } + + want := map[string]string{ + "imported/aws/default": "AKIA/secret+with=specials", + "imported/env/api": "unicode-末-\U0001f511", + } + for path, exp := range want { + got, err := os.ReadFile(filepath.Join(dst, path)) + if err != nil { + t.Fatalf("restored %s: %v", path, err) + } + if string(got) != exp { + t.Errorf("%s: got %q want %q", path, got, exp) + } + } + if _, err := os.Stat(filepath.Join(dst, "other", "skip")); !os.IsNotExist(err) { + t.Error("other/skip leaked across the prefix filter") + } +} + +func TestStorePathNoDoubleSegment(t *testing.T) { + gp := &Gopass{Prefix: "imported/"} + c := discover.Credential{Source: "file", Identity: "keys/deploy"} + if got := gp.StorePath(c); got != "imported/file/keys/deploy" { + t.Errorf("StorePath = %q, want imported/file/keys/deploy", got) + } +} diff --git a/internal/sink/gopass.go b/internal/sink/gopass.go new file mode 100644 index 0000000..137cb58 --- /dev/null +++ b/internal/sink/gopass.go @@ -0,0 +1,95 @@ +// Package sink writes secrets out of the RAM vault. gopass is the live store +// (GPG); openssl produces portable sealed backups. Plaintext flows vault → pipe +// → external tool's stdin, and never touches the filesystem in cleartext. +package sink + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "path" + "strings" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// Gopass streams secrets into `gopass insert`. The binary path is configurable +// for testing; defaults to "gopass" on PATH. +type Gopass struct { + Bin string // default "gopass" + Prefix string // default "imported/" +} + +func (g *Gopass) bin() string { + if g.Bin == "" { + return "gopass" + } + return g.Bin +} + +// Available reports whether gopass is usable. +func (g *Gopass) Available() bool { + _, err := exec.LookPath(g.bin()) + return err == nil +} + +// StorePath is the gopass entry path for a credential: //. +func (g *Gopass) StorePath(c discover.Credential) string { + prefix := g.Prefix + if prefix == "" { + prefix = "imported/" + } + return path.Join(prefix, c.Source, slug(c.Identity)) +} + +// Insert writes one credential's secret into gopass. The plaintext is taken from +// the locked vault buffer and piped directly to gopass stdin; we never build a +// plaintext string or temp file. `-f` forces overwrite; caller decides dedupe. +func (g *Gopass) Insert(ctx context.Context, v *vault.Vault, c discover.Credential, force bool) error { + buf, err := v.Open(c.Secret) + if err != nil { + return err + } + + args := []string{"insert", "--multiline=false"} + if force { + args = append(args, "-f") + } + args = append(args, g.StorePath(c)) + + cmd := exec.CommandContext(ctx, g.bin(), args...) + // buf.Bytes() is the locked memory; gopass reads it from the pipe and GPG- + // encrypts. The bytes are never copied into a managed Go string. + cmd.Stdin = bytes.NewReader(buf.Bytes()) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("gopass insert %q: %v: %s", g.StorePath(c), err, stderr.String()) + } + // Shrink the in-memory window: this secret is now safely in gopass. + v.Forget(c.Secret) + return nil +} + +// Exists reports whether an entry already exists (for --dedupe). +func (g *Gopass) Exists(ctx context.Context, storePath string) bool { + cmd := exec.CommandContext(ctx, g.bin(), "ls", "--flat") + out, err := cmd.Output() + if err != nil { + return false + } + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == storePath { + return true + } + } + return false +} + +func slug(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + repl := strings.NewReplacer(" / ", "/", " ", "-", "*", "") + return repl.Replace(s) +} diff --git a/internal/sink/hmac.go b/internal/sink/hmac.go new file mode 100644 index 0000000..1b7ac63 --- /dev/null +++ b/internal/sink/hmac.go @@ -0,0 +1,100 @@ +package sink + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "fmt" + "io" + + "github.com/awnumar/memguard" +) + +// HMACSealer is an authenticated Sealer that needs no age dependency — only the +// openssl CLI. It is encrypt-then-MAC: +// +// - confidentiality: AES-256-CTR via `openssl enc`, key = PBKDF2(passphrase, +// salt) (the salt rides in openssl's `Salted__` header). +// - integrity: HMAC-SHA256 over the ciphertext, key = the passphrase bytes, +// computed in-process. +// +// The MAC is verified BEFORE any decryption, so a tampered or corrupt bundle is +// rejected rather than silently decrypted (the property plain `openssl enc` +// can't give on OpenSSL 3.x, which refuses AEAD ciphers). +// +// Bundle layout: HMAC-SHA256(32 bytes) || openssl-CTR-ciphertext +// +// Unlike the age sealer, the passphrase here is never a Go string: openssl gets +// the cipher key over fd 3, and the MAC key is the locked-buffer bytes. +// +// Hand recovery (also in docs/SECURITY.md): +// +// head -c 32 bundle > mac.bin +// tail -c +33 bundle > ct.bin +// openssl dgst -sha256 -hmac "$PASS" -binary ct.bin | cmp - mac.bin # integrity gate +// openssl enc -d -aes-256-ctr -pbkdf2 -iter 600000 -pass pass:"$PASS" -in ct.bin +type HMACSealer struct { + Bin string // default "openssl" + Iter int // default 600000 +} + +func (h *HMACSealer) bin() string { + if h.Bin == "" { + return "openssl" + } + return h.Bin +} + +func (h *HMACSealer) iter() int { + if h.Iter == 0 { + return 600000 + } + return h.Iter +} + +func (h *HMACSealer) Name() string { return "hmac" } + +func (h *HMACSealer) cipher(ctx context.Context, decrypt bool, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error { + args := []string{"enc", "-aes-256-ctr", "-pbkdf2", "-iter", fmt.Sprint(h.iter()), "-pass", "fd:3"} + if decrypt { + args = append(args, "-d") + } else { + args = append(args, "-salt") + } + return opensslEnc(ctx, h.bin(), args, in, out, pass) +} + +func (h *HMACSealer) Seal(ctx context.Context, plaintext io.Reader, out io.Writer, pass *memguard.LockedBuffer) error { + // Encrypt to a buffer (ciphertext is not secret), MAC it, then write + // mac || ciphertext so the reader can authenticate before decrypting. + var ct bytes.Buffer + if err := h.cipher(ctx, false, plaintext, &ct, pass); err != nil { + return err + } + mac := hmac.New(sha256.New, pass.Bytes()) + mac.Write(ct.Bytes()) + if _, err := out.Write(mac.Sum(nil)); err != nil { + return err + } + _, err := out.Write(ct.Bytes()) + return err +} + +func (h *HMACSealer) Open(ctx context.Context, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error { + all, err := io.ReadAll(in) + if err != nil { + return err + } + if len(all) < sha256.Size { + return fmt.Errorf("hmac: bundle too short (%d bytes)", len(all)) + } + want, ct := all[:sha256.Size], all[sha256.Size:] + + mac := hmac.New(sha256.New, pass.Bytes()) + mac.Write(ct) + if !hmac.Equal(want, mac.Sum(nil)) { + return fmt.Errorf("hmac: authentication failed (wrong passphrase, or tampered/corrupt bundle)") + } + return h.cipher(ctx, true, bytes.NewReader(ct), out, pass) +} diff --git a/internal/sink/openssl.go b/internal/sink/openssl.go new file mode 100644 index 0000000..bffeace --- /dev/null +++ b/internal/sink/openssl.go @@ -0,0 +1,99 @@ +package sink + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + + "github.com/awnumar/memguard" +) + +// OpenSSL is a non-default Sealer kept for environments that have the openssl CLI +// but not age, and for hand-restorability with a one-line openssl command. +// +// openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -in backup.enc +// +// IMPORTANT — this mode is UNAUTHENTICATED. OpenSSL 3.x's `enc` command refuses +// AEAD ciphers ("AEAD ciphers not supported"), so there is no GCM/Poly1305 tag: +// a tampered or truncated bundle will decrypt to garbage with no error. Prefer the +// age sealer (see age.go), which is authenticated, for any backup you care about. +// +// The passphrase is handed to openssl over an inherited file descriptor +// (`-pass fd:3`), never via argv (visible in `ps`) or the environment (visible in +// /proc//environ). The bytes come straight from a locked buffer. +type OpenSSL struct { + Bin string // default "openssl" + Iter int // default 600000 +} + +func (o *OpenSSL) bin() string { + if o.Bin == "" { + return "openssl" + } + return o.Bin +} + +func (o *OpenSSL) iter() int { + if o.Iter == 0 { + return 600000 + } + return o.Iter +} + +func (o *OpenSSL) Name() string { return "openssl" } + +func (o *OpenSSL) run(ctx context.Context, decrypt bool, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error { + args := []string{"enc", "-aes-256-cbc", "-pbkdf2", "-iter", fmt.Sprint(o.iter()), "-pass", "fd:3"} + if decrypt { + args = append(args, "-d") + } else { + args = append(args, "-salt") + } + return opensslEnc(ctx, o.bin(), args, in, out, pass) +} + +func (o *OpenSSL) Seal(ctx context.Context, plaintext io.Reader, out io.Writer, pass *memguard.LockedBuffer) error { + return o.run(ctx, false, plaintext, out, pass) +} + +func (o *OpenSSL) Open(ctx context.Context, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error { + return o.run(ctx, true, in, out, pass) +} + +// opensslEnc runs `openssl ` with stdin/stdout wired to in/out and the +// passphrase delivered over inherited fd 3. The caller must include +// `-pass fd:3` in args. The passphrase bytes come straight from a locked buffer — +// they never appear in argv (visible in `ps`) or the environment (visible in +// /proc//environ). Shared by the OpenSSL and HMAC sealers. +func opensslEnc(ctx context.Context, bin string, args []string, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error { + pr, pw, err := os.Pipe() + if err != nil { + return err + } + + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Stdin = in + cmd.Stdout = out + cmd.ExtraFiles = []*os.File{pr} // child sees this as fd 3 + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + pr.Close() + pw.Close() + return err + } + pr.Close() // the child holds its own copy of the read end + + // Feed the passphrase from locked memory, then close so openssl sees EOF. + _, werr := pw.Write(pass.Bytes()) + pw.Close() + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("%s: %v: %s", bin, err, stderr.String()) + } + return werr +} diff --git a/internal/sink/sealer.go b/internal/sink/sealer.go new file mode 100644 index 0000000..d9d1725 --- /dev/null +++ b/internal/sink/sealer.go @@ -0,0 +1,24 @@ +package sink + +import ( + "context" + "io" + + "github.com/awnumar/memguard" +) + +// Sealer produces and opens portable, agent-independent encrypted backup bundles. +// +// gopass already GPG-encrypts every entry, so incredigo deliberately does NOT +// double-wrap individual secrets. A Sealer instead seals the whole exported set +// into one blob that can be restored on a bare machine with nothing but this one +// tool and a passphrase — no gpg-agent, no keyring. +// +// openssl is the default implementation (see openssl.go). Because this is an +// interface, a contributor can drop in age (github.com/FiloSottile/age) without +// touching anything else in incredigo. +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 +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go new file mode 100644 index 0000000..ea647be --- /dev/null +++ b/internal/vault/vault.go @@ -0,0 +1,90 @@ +// Package vault is the RAM-only custody core of incredigo. +// +// All decrypted secret material lives here and nowhere else. Buffers are backed +// by github.com/awnumar/memguard, which mlocks the pages (no swap), marks them +// MADV_DONTDUMP (no core dumps), surrounds them with guard pages, and zeroizes +// on Destroy. Plaintext is never returned as a Go string (strings are immutable, +// GC-managed and cannot be reliably wiped) — callers receive a Handle and must +// Open it for a short-lived locked buffer they then Destroy. +package vault + +import ( + "fmt" + "sync" + + "github.com/awnumar/memguard" +) + +// Handle is an opaque reference to a secret stored in the vault. It contains no +// plaintext and is safe to pass around, log (it has no String of the secret), +// and embed in metadata records. +type Handle struct { + id uint64 +} + +// Vault owns every live secret buffer for the process lifetime. +type Vault struct { + mu sync.Mutex + next uint64 + bufs map[uint64]*memguard.LockedBuffer +} + +// New creates a vault and installs a signal handler so secrets are purged from +// memory on SIGINT/SIGTERM as well as normal exit. +func New() *Vault { + memguard.CatchInterrupt() // wipes all locked buffers on interrupt, then exits + return &Vault{bufs: make(map[uint64]*memguard.LockedBuffer)} +} + +// Store copies plaintext into a freshly locked buffer and wipes the caller's +// slice. The returned Handle is the only way back to the bytes. +func (v *Vault) Store(plaintext []byte) *Handle { + v.mu.Lock() + defer v.mu.Unlock() + + buf := memguard.NewBufferFromBytes(plaintext) // also wipes the source slice + id := v.next + v.next++ + v.bufs[id] = buf + return &Handle{id: id} +} + +// Open returns the locked buffer for a handle. The caller MUST treat the bytes +// as ephemeral: use them immediately (e.g. write to a pipe) and do not copy them +// into a string. The buffer is owned by the vault; do not Destroy it directly — +// use Forget or Purge. +func (v *Vault) Open(h *Handle) (*memguard.LockedBuffer, error) { + v.mu.Lock() + defer v.mu.Unlock() + + buf, ok := v.bufs[h.id] + if !ok { + return nil, fmt.Errorf("vault: unknown or already-purged handle %d", h.id) + } + return buf, nil +} + +// Forget destroys (zeroizes + unlocks) a single secret as soon as it is no +// longer needed, shrinking the in-memory window. +func (v *Vault) Forget(h *Handle) { + v.mu.Lock() + defer v.mu.Unlock() + + if buf, ok := v.bufs[h.id]; ok { + buf.Destroy() + delete(v.bufs, h.id) + } +} + +// Purge zeroizes and frees every secret. Call from a deferred cleanup at the top +// of every command so plaintext never outlives the run. +func (v *Vault) Purge() { + v.mu.Lock() + defer v.mu.Unlock() + + for id, buf := range v.bufs { + buf.Destroy() + delete(v.bufs, id) + } + memguard.Purge() +} diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go new file mode 100644 index 0000000..6bca850 --- /dev/null +++ b/internal/vault/vault_test.go @@ -0,0 +1,67 @@ +package vault + +import ( + "bytes" + "testing" +) + +func TestStoreWipesCallerAndRoundTrips(t *testing.T) { + v := New() + defer v.Purge() + + secret := []byte("super-secret-value") + want := append([]byte(nil), secret...) + + h := v.Store(secret) + + // Store must wipe the caller's slice (memguard NewBufferFromBytes contract). + if !bytes.Equal(secret, make([]byte, len(secret))) { + t.Errorf("Store did not wipe caller slice, got %q", secret) + } + + buf, err := v.Open(h) + if err != nil { + t.Fatalf("Open: %v", err) + } + if !bytes.Equal(buf.Bytes(), want) { + t.Errorf("round-trip mismatch: got %q want %q", buf.Bytes(), want) + } +} + +func TestForgetZeroizes(t *testing.T) { + v := New() + defer v.Purge() + + h := v.Store([]byte("wipe-me")) + buf, err := v.Open(h) + if err != nil { + t.Fatalf("Open: %v", err) + } + if !buf.IsAlive() { + t.Fatal("buffer should be alive before Forget") + } + + v.Forget(h) + + if buf.IsAlive() { + t.Error("buffer should be destroyed (zeroized) after Forget") + } + if _, err := v.Open(h); err == nil { + t.Error("Open after Forget should return an error for the dead handle") + } +} + +func TestPurgeDestroysAll(t *testing.T) { + v := New() + h1 := v.Store([]byte("a")) + h2 := v.Store([]byte("b")) + + v.Purge() + + if _, err := v.Open(h1); err == nil { + t.Error("h1 should be gone after Purge") + } + if _, err := v.Open(h2); err == nil { + t.Error("h2 should be gone after Purge") + } +}