Initial commit: Incredigo — local-first RAM-only credential custody

v1: discover → migrate → expiry tracking → sealed export/import.

- vault: memguard mlock/DONTDUMP arena, handle indirection, zeroize-on-drop
- discover: registry + 8 read-only scanners (aws, env, netrc, ssh, docker,
  kube, git) and a file/dir harvester (--path)
- sink: gopass streaming insert; length-prefixed bundle framing; Sealer
  interface with three impls — age (default, authenticated), hmac
  (authenticated, openssl-only encrypt-then-MAC), openssl (CBC fallback,
  unauthenticated; OpenSSL 3.x enc refuses AEAD)
- policy: local expiry engine, 60d/8w threshold parser
- audit: redacted append-only JSONL, injectable clock
- export/import: passphrase from no-echo prompt or $INCREDIGO_PASSPHRASE into
  locked memory; secrets stream gopass<->Sealer as bytes, never Go strings
- tests: scanner leak-checks, vault zeroize, bundle round-trip via fake gopass;
  go test ./... green, -race clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-15 07:56:31 -07:00
commit 425c299359
39 changed files with 3552 additions and 0 deletions
+14
View File
@@ -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
+296
View File
@@ -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 <path>` over the
process's **stdin pipe**. gopass performs GPG encryption to your key. Plaintext
flows RAM-vault → pipe → gopass's GPG layer; **it never touches the filesystem.**
```
incredigo ──(os.Pipe, locked buffer)──▶ gopass insert imported/aws/default ──▶ GPG blob on disk
```
Path convention: `<prefix>/<source>/<identity>` (default prefix `imported/`),
e.g. `imported/aws/default`, `imported/docker/registry.example.com`.
**b) the sealed backup/transport bundle.**
gopass already GPG-encrypts every entry, so we do **not** double-wrap individual
secrets. Instead a `Sealer` produces a single portable, **agent-independent** bundle
for disaster recovery / moving to a fresh machine where no gpg-agent or keyring
exists yet. One passphrase restores everything.
The bundle's plaintext side is a flat, length-prefixed record stream
(`uint16 pathLen | path | chunk* | end`, see `internal/sink/bundle.go`); secrets
stream `gopass show` → frame → Sealer and back, so a whole secret is never held in
one buffer or turned into a Go string. The `Sealer` then wraps that stream:
```go
// internal/sink/sealer.go
type Sealer interface {
Name() string
Seal(ctx context.Context, plaintext io.Reader, out io.Writer, pass *memguard.LockedBuffer) error
Open(ctx context.Context, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error
}
```
**Default: `age`** (`--sealer age`). OpenSSL 3.x's `enc` command **refuses AEAD
ciphers** outright (`enc: AEAD ciphers not supported`), so the originally-planned
`openssl enc -aes-256-gcm` cannot authenticate a bundle on a modern box — a tampered
or corrupt backup would decrypt to silent garbage. We therefore default to
[`age`](https://github.com/FiloSottile/age) (ChaCha20-Poly1305 + scrypt), which is
authenticated and hand-restorable with the standard `age` CLI. Three sealers ship,
all behind the one interface:
| `--sealer` | Authenticated | Mechanism | Hand-restore |
|---|---|---|---|
| `age` (default) | ✅ | age format (ChaCha20-Poly1305, scrypt) | `age -d backup.incredigo.age` |
| `hmac` | ✅ | `openssl enc -aes-256-ctr` + in-process HMAC-SHA256 (encrypt-then-MAC, verified before decrypt) | `openssl dgst -hmac` then `openssl enc -d` (see SECURITY.md) |
| `openssl` | ❌ | `openssl enc -aes-256-cbc -pbkdf2` | `openssl enc -d -aes-256-cbc -pbkdf2` |
> The passphrase is delivered to openssl over an inherited fd (`-pass fd:3`), never
> argv or env. For `age` it transits a Go string (the library's API takes a
> `string`); for `hmac` it never does (the MAC key is locked-buffer bytes). The
> `openssl` sealer is a last-resort fallback only: it is **unauthenticated**.
### 3.4 Expiry tracker
A pure-local policy engine. No network in v1. Reads `Modified` (and any
provider-known max-age) and reports against thresholds.
```yaml
# ~/.config/incredigo/policy.yaml
defaults: { warn_after: 60d, stale_after: 90d }
overrides:
aws_key: { warn_after: 30d, stale_after: 90d }
private_key: { warn_after: 180d }
```
`incredigo status` prints a table: identity, source, age, state (`ok`/`warn`/`stale`).
No secrets are ever printed.
---
## 4. Threat model & guarantees
| Threat | Mitigation |
|---|---|
| Plaintext leaks to disk | Never written. gopass GPG blobs or openssl bundles only. No temp files. |
| Plaintext leaks via swap | `mlock` on all secret buffers. |
| Plaintext leaks via core dump / `/proc/<pid>/mem` snapshot | `MADV_DONTDUMP`; `memguard` guard pages; zeroize on drop. |
| Source files corrupted by the tool | Scanners are strictly read-only (open `O_RDONLY`). |
| Old cred destroyed before replacement verified | v1 never deletes anything. (Rotation, when added, verifies-new before revoke-old.) |
| Secrets in logs | Append-only audit log redacts all secret material; `Identity` fields are pre-redacted. |
| Network exfiltration | No network calls in v1; future rotation adapters are per-provider, explicit, opt-in. |
**Out of scope / honest limits:** `incredigo` cannot protect against a compromised
machine while it runs (a kernel-level attacker or a malicious gpg-agent can see
plaintext in use). It minimizes the *window* and the *footprint*, not the
fundamental trust in your own OS and GPG key.
---
## 5. CLI surface (v1)
```
incredigo scan [--source aws,env,...] [--path FILE|DIR ...] [--json] # discover (NO secrets printed)
incredigo migrate [--prefix imported/] [--dedupe] [--force] [--source ...] [--path ...]
incredigo status [--source ...] [--path ...] # expiry/age report against policy
incredigo export [--sealer age|hmac|openssl] --out backup.incredigo.age # sealed bundle of the prefix
incredigo import [--sealer age|hmac|openssl] --in backup.incredigo.age # restore a bundle into gopass
```
Global flags: `--dry-run`, `--audit-log <path>`, `--config <path>`,
`--sealer <age|hmac|openssl>`. `--path` (repeatable) harvests an arbitrary file or
directory via the `file` scanner. The export/import passphrase is read from a
no-echo prompt or `$INCREDIGO_PASSPHRASE` into locked memory.
Typical first run:
```
incredigo scan --dry-run # see what's out there
incredigo migrate --prefix imported/ --dedupe
incredigo status # what's already stale
incredigo export --out ~/incredigo-backup.age # authenticated (age) by default
```
---
## 6. Repository layout
```
incredigo/
cmd/incredigo/ # cobra entrypoint: scan|migrate|status|export|import
internal/vault/ # memguard arena: Store/Open/Purge, zeroize, SIGINT handler
internal/discover/ # registry + Scanner interface + one file per scanner
aws.go env.go netrc.go ssh.go docker.go kube.go git.go file.go
internal/sink/
gopass.go # streaming writer over gopass insert stdin
bundle.go # plaintext record framing: gopass <-> Sealer
sealer.go # Sealer interface
age.go # default sealer (authenticated)
hmac.go # authenticated openssl-only sealer (encrypt-then-MAC)
openssl.go # unauthenticated fallback sealer (AES-256-CBC)
internal/policy/ # expiry rules, age thresholds, policy.yaml loader
internal/audit/ # redacted append-only log
docs/
ADDING_A_SCANNER.md
SECURITY.md # threat model (mirror of §4) + responsible disclosure
DESIGN.md # this file
README.md
go.mod
```
The two extension points — **the scanner registry** and **the `Sealer` interface**
— are what make `incredigo` easy for others to build on.
---
## 7. Roadmap beyond v1
- **v2 — rotation adapters** behind a `Rotator` interface: `aws iam create-access-key`
→ verify → `delete-access-key`; GitHub/GitLab PAT via API; SSH keygen + push to
`authorized_keys`. Verify-new-before-revoke-old, always.
- **v2 — guided manual rotation** for creds with no automatable path (DB passwords).
- **Later** — TUI for review/approve of discovered creds; gopass mount/git sync hooks.
+45
View File
@@ -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.
+113
View File
@@ -0,0 +1,113 @@
<div align="center">
<img src="docs/assets/banner.svg" alt="Incredigo — guards your credentials in locked memory, leaves no trace on disk" width="760">
# Incredigo
**Guards your credentials in locked memory. Leaves no trace on disk.**
*Local-first, encrypted, RAM-only credential custody — shipped as the `incredigo` CLI.*
</div>
```
+------------------------------------------------------------+
| /\ 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).
+426
View File
@@ -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)
}
+86
View File
@@ -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.
+61
View File
@@ -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).
+91
View File
@@ -0,0 +1,91 @@
<!doctype html><html><head><meta charset="utf-8"><style>
html,body{margin:0;background:#060504;font-family:'JetBrainsMono Nerd Font','JetBrains Mono',monospace}
.grid{display:flex;gap:0}
.card{width:300px;height:360px;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:24px;border-right:1px solid #1c1714}
.card:last-child{border:none}
.lbl{color:#F7B968;font-size:15px;font-weight:700;letter-spacing:1px}
.sub{color:#8a7c72;font-size:11px;margin-top:-16px}
/* shared font for the in-svg wordmarks */
.mono{font-family:'JetBrainsMono Nerd Font','JetBrainsMonoNL Nerd Font','JetBrains Mono','Fira Code',monospace;font-weight:700}
</style></head><body>
<div class="grid">
<!-- ===== A: dark tile, igo, diamond i-dot (hero) ===== -->
<div class="card">
<svg width="180" height="180" viewBox="0 0 200 200">
<defs>
<linearGradient id="ta" x1="0" y1="0" x2="200" y2="200" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1A1410"/><stop offset="1" stop-color="#0B0806"/></linearGradient>
<linearGradient id="la" x1="40" y1="80" x2="160" y2="150" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#F7B968"/><stop offset="0.6" stop-color="#E8772E"/><stop offset="1" stop-color="#C2531A"/></linearGradient>
</defs>
<rect width="200" height="200" rx="44" fill="url(#ta)" stroke="#2a201a" stroke-width="2"/>
<!-- custom i stem -->
<rect x="44" y="92" width="15" height="54" rx="6" fill="url(#la)"/>
<!-- diamond i-dot (nested strata) -->
<path d="M51.5 60 L66 74 L51.5 88 L37 74 Z" fill="none" stroke="url(#la)" stroke-width="4"/>
<path d="M51.5 68 L58 74 L51.5 80 L45 74 Z" fill="#F7B968"/>
<!-- g o -->
<text x="128" y="146" text-anchor="middle" class="mono" font-size="96" fill="url(#la)">go</text>
</svg>
<div class="lbl">A · DIAMOND-DOT</div><div class="sub">dark tile · i-dot = Strata core</div>
</div>
<!-- ===== B: orange tile, knockout igo ===== -->
<div class="card">
<svg width="180" height="180" viewBox="0 0 200 200">
<defs>
<linearGradient id="tb" x1="0" y1="0" x2="200" y2="200" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#F8A23E"/><stop offset="1" stop-color="#C2531A"/></linearGradient>
</defs>
<rect width="200" height="200" rx="44" fill="url(#tb)"/>
<rect x="44" y="92" width="15" height="54" rx="6" fill="#1A0E06"/>
<path d="M51.5 60 L66 74 L51.5 88 L37 74 Z" fill="#1A0E06"/>
<text x="128" y="146" text-anchor="middle" class="mono" font-size="96" fill="#1A0E06">go</text>
</svg>
<div class="lbl">B · KNOCKOUT</div><div class="sub">orange tile · bold solid</div>
</div>
<!-- ===== C: Strata mark + igo lockup ===== -->
<div class="card">
<svg width="180" height="180" viewBox="0 0 200 200">
<defs>
<linearGradient id="tc" x1="0" y1="0" x2="200" y2="200" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1A1410"/><stop offset="1" stop-color="#0B0806"/></linearGradient>
<linearGradient id="lc" x1="40" y1="20" x2="160" y2="170" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#F7B968"/><stop offset="0.6" stop-color="#E8772E"/><stop offset="1" stop-color="#C2531A"/></linearGradient>
</defs>
<rect width="200" height="200" rx="44" fill="url(#tc)" stroke="#2a201a" stroke-width="2"/>
<g fill="none" stroke="url(#lc)" stroke-linejoin="round" transform="translate(100,78)">
<path d="M0 -46 L46 0 L0 46 L-46 0 Z" stroke-width="8" opacity="0.45"/>
<path d="M0 -26 L26 0 L0 26 L-26 0 Z" stroke-width="8"/>
</g>
<path d="M100 70 L108 78 L100 86 L92 78 Z" fill="#F7B968" transform="translate(0,0)"/>
<text x="100" y="168" text-anchor="middle" class="mono" font-size="46" letter-spacing="4" fill="url(#lc)">igo</text>
</svg>
<div class="lbl">C · STACK</div><div class="sub">Strata mark over igo</div>
</div>
<!-- ===== D: ig + diamond-o (o = vault) ===== -->
<div class="card">
<svg width="180" height="180" viewBox="0 0 200 200">
<defs>
<linearGradient id="td" x1="0" y1="0" x2="200" y2="200" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1A1410"/><stop offset="1" stop-color="#0B0806"/></linearGradient>
<linearGradient id="ld" x1="30" y1="80" x2="170" y2="150" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#F7B968"/><stop offset="0.6" stop-color="#E8772E"/><stop offset="1" stop-color="#C2531A"/></linearGradient>
</defs>
<rect width="200" height="200" rx="44" fill="url(#td)" stroke="#2a201a" stroke-width="2"/>
<text x="62" y="146" text-anchor="middle" class="mono" font-size="96" fill="url(#ld)">ig</text>
<!-- diamond-o -->
<g fill="none" stroke="url(#ld)" stroke-linejoin="round" transform="translate(146,116)">
<path d="M0 -30 L30 0 L0 30 L-30 0 Z" stroke-width="9"/>
<path d="M0 -13 L13 0 L0 13 L-13 0 Z" stroke-width="7"/>
</g>
</svg>
<div class="lbl">D · DIAMOND-O</div><div class="sub">the 'o' becomes the vault</div>
</div>
</div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

+61
View File
@@ -0,0 +1,61 @@
<svg width="1200" height="360" viewBox="0 0 1200 360" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Incredigo — guards your credentials in locked memory, leaves no trace on disk">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="360" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#161210"/>
<stop offset="1" stop-color="#0C0907"/>
</linearGradient>
<linearGradient id="fur" x1="40" y1="40" x2="300" y2="320" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#F08A2C"/>
<stop offset="0.55" stop-color="#D9661A"/>
<stop offset="1" stop-color="#A8410F"/>
</linearGradient>
<linearGradient id="word" x1="360" y1="120" x2="360" y2="200" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#F7B968"/>
<stop offset="1" stop-color="#E8772E"/>
</linearGradient>
<radialGradient id="core" cx="256" cy="256" r="40" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FBD9A6"/>
<stop offset="1" stop-color="#F08A2C"/>
</radialGradient>
</defs>
<rect width="1200" height="360" rx="20" fill="url(#bg)"/>
<!-- ===== Mark: STRATA (nested diamonds, 512 art scaled *0.5) ===== -->
<g transform="translate(54,42) scale(0.5)">
<g fill="none" stroke="url(#fur)" stroke-linejoin="round">
<path d="M256 32 L480 256 L256 480 L32 256 Z" stroke-width="18" opacity="0.42"/>
<path d="M256 104 L408 256 L256 408 L104 256 Z" stroke-width="18" opacity="0.68"/>
<path d="M256 172 L340 256 L256 340 L172 256 Z" stroke-width="18"/>
</g>
<path d="M256 222 L290 256 L256 290 L222 256 Z" fill="url(#core)"/>
</g>
<!-- ===== Wordmark ===== -->
<text x="360" y="158" font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace"
font-size="68" font-weight="700" letter-spacing="3" fill="#8C5E2E"><tspan>in</tspan><tspan fill="url(#word)">CRED</tspan><tspan>i</tspan><tspan fill="url(#word)">GO</tspan></text>
<text x="362" y="206" font-family="'Inter','DejaVu Sans',sans-serif"
font-size="25" font-weight="500" fill="#C9BBB0">Guards your credentials in locked memory.</text>
<text x="362" y="240" font-family="'Inter','DejaVu Sans',sans-serif"
font-size="25" font-weight="500" fill="#C9BBB0">Leaves no trace on disk.</text>
<!-- guarantee chips -->
<g font-family="'JetBrains Mono',ui-monospace,monospace" font-size="16" font-weight="600">
<g transform="translate(362,278)">
<rect width="150" height="34" rx="17" fill="#1F1611" stroke="#D9661A" stroke-width="1.5"/>
<text x="75" y="22" text-anchor="middle" fill="#F7B968">local-first</text>
</g>
<g transform="translate(524,278)">
<rect width="138" height="34" rx="17" fill="#1F1611" stroke="#D9661A" stroke-width="1.5"/>
<text x="69" y="22" text-anchor="middle" fill="#F7B968">RAM-only</text>
</g>
<g transform="translate(674,278)">
<rect width="118" height="34" rx="17" fill="#1F1611" stroke="#D9661A" stroke-width="1.5"/>
<text x="59" y="22" text-anchor="middle" fill="#F7B968">offline</text>
</g>
<g transform="translate(804,278)">
<rect width="196" height="34" rx="17" fill="#1F1611" stroke="#D9661A" stroke-width="1.5"/>
<text x="98" y="22" text-anchor="middle" fill="#F7B968">no plaintext at rest</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+6
View File
@@ -0,0 +1,6 @@
+------------------------------------------------------------+
| /\ INCREDIGO |
| /<>\ in-CRED-i-GO : credentials, in Go |
| \<>/ Guards your credentials in locked memory. |
| \/ Leaves no trace on disk. |
+------------------------------------------------------------+
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Incredigo logo: nested diamonds around a sealed core — layered credential custody">
<defs>
<linearGradient id="strata" x1="48" y1="48" x2="464" y2="464" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#F7B968"/>
<stop offset="0.5" stop-color="#E8772E"/>
<stop offset="1" stop-color="#A8410F"/>
</linearGradient>
<radialGradient id="core" cx="256" cy="256" r="40" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FBD9A6"/>
<stop offset="1" stop-color="#F08A2C"/>
</radialGradient>
</defs>
<g fill="none" stroke="url(#strata)" stroke-linejoin="round">
<path d="M256 32 L480 256 L256 480 L32 256 Z" stroke-width="18" opacity="0.42"/>
<path d="M256 104 L408 256 L256 408 L104 256 Z" stroke-width="18" opacity="0.68"/>
<path d="M256 172 L340 256 L256 340 L172 256 Z" stroke-width="18"/>
</g>
<path d="M256 222 L290 256 L256 290 L222 256 Z" fill="url(#core)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+20
View File
@@ -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
)
+29
View File
@@ -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=
+67
View File
@@ -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()
}
+48
View File
@@ -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)
}
}
+103
View File
@@ -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:]
}
+101
View File
@@ -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
}
+78
View File
@@ -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
}
+105
View File
@@ -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
}
+120
View File
@@ -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/<rel>.
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
}
+71
View File
@@ -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()
}
+84
View File
@@ -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
}
+109
View File
@@ -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
}
+287
View File
@@ -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")
}
+67
View File
@@ -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
}
+119
View File
@@ -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
}
+82
View File
@@ -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)
}
}
}
+68
View File
@@ -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
}
+192
View File
@@ -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
}
+108
View File
@@ -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)
}
}
+95
View File
@@ -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: <prefix>/<source>/<slug>.
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)
}
+100
View File
@@ -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)
}
+99
View File
@@ -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/<pid>/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 <args...>` 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/<pid>/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
}
+24
View File
@@ -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
}
+90
View File
@@ -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()
}
+67
View File
@@ -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")
}
}