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:
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user