Wave 0: module init + test scaffolding (01-01) Wave 1: provider registry (01-02) + storage layer (01-03) in parallel Wave 2: scan engine pipeline (01-04, depends on 01-02) Wave 3: CLI wiring + integration checkpoint (01-05, depends on all) Covers all 16 Phase 1 requirements: CORE-01 through CORE-07, STOR-01 through STOR-03, CLI-01 through CLI-05, PROV-10. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
22 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-foundation | 03 | execute | 1 |
|
|
true |
|
|
Purpose: Scanner results from Plan 04 and CLI commands from Plan 05 need a storage layer to persist findings. The encryption contract (Encrypt/Decrypt/DeriveKey) must exist before the scanner pipeline can store keys. Output: pkg/storage/{encrypt,crypto,db,findings,schema}.go and db_test.go (stubs filled).
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/phases/01-foundation/01-RESEARCH.md @.planning/phases/01-foundation/01-01-SUMMARY.md func Encrypt(plaintext []byte, key []byte) ([]byte, error) // key must be exactly 32 bytes (AES-256) // nonce prepended to ciphertext in returned []byte // uses crypto/aes + crypto/cipher GCMfunc Decrypt(ciphertext []byte, key []byte) ([]byte, error) // expects nonce prepended format from Encrypt() // returns ErrCiphertextTooShort if len < nonceSize
func DeriveKey(passphrase []byte, salt []byte) []byte // params: time=1, memory=64*1024, threads=4, keyLen=32 // returns exactly 32 bytes deterministically
func NewSalt() ([]byte, error) // generates 16 random bytes via crypto/rand
findings table columns: id INTEGER PRIMARY KEY AUTOINCREMENT scan_id INTEGER REFERENCES scans(id) provider_name TEXT NOT NULL key_value BLOB NOT NULL -- AES-256-GCM encrypted, nonce prepended key_masked TEXT NOT NULL -- first8...last4, stored plaintext for display confidence TEXT NOT NULL -- "high", "medium", "low" source_path TEXT source_type TEXT -- "file", "dir", "git", "stdin", "url" line_number INTEGER created_at DATETIME DEFAULT CURRENT_TIMESTAMP
scans table columns: id INTEGER PRIMARY KEY AUTOINCREMENT started_at DATETIME NOT NULL finished_at DATETIME source_path TEXT finding_count INTEGER DEFAULT 0 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
settings table columns: key TEXT PRIMARY KEY value TEXT NOT NULL updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
type Finding struct { ID int64 ScanID int64 ProviderName string KeyValue string // plaintext — encrypted before storage KeyMasked string // first8chars...last4chars Confidence string SourcePath string SourceType string LineNumber int }
import _ "modernc.org/sqlite" // driver registered as "sqlite" (NOT "sqlite3") db, err := sql.Open("sqlite", dataSourceName)
Task 1: AES-256-GCM encryption and Argon2id key derivation pkg/storage/encrypt.go, pkg/storage/crypto.go - /home/salva/Documents/apikey/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 3: AES-256-GCM Column Encryption and Pattern 4: Argon2id Key Derivation — exact code examples) - Test 1: Encrypt then Decrypt same key → returns original plaintext exactly - Test 2: Encrypt produces output longer than input (nonce + tag overhead) - Test 3: Two Encrypt calls on same plaintext → different ciphertext (random nonce) - Test 4: Decrypt with wrong key → returns error (GCM authentication fails) - Test 5: DeriveKey with same passphrase+salt → same 32-byte output (deterministic) - Test 6: DeriveKey output is exactly 32 bytes - Test 7: NewSalt() returns 16 bytes, two calls return different values Create **pkg/storage/encrypt.go**: ```go package storageimport ( "crypto/aes" "crypto/cipher" "crypto/rand" "errors" "io" )
// ErrCiphertextTooShort is returned when ciphertext is shorter than the GCM nonce size. var ErrCiphertextTooShort = errors.New("ciphertext too short")
// Encrypt encrypts plaintext using AES-256-GCM with a random nonce. // The nonce is prepended to the returned ciphertext. // key must be exactly 32 bytes (AES-256). func Encrypt(plaintext []byte, key []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } // Seal appends encrypted data to nonce, so nonce is prepended ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) return ciphertext, nil }
// Decrypt decrypts ciphertext produced by Encrypt. // Expects the nonce to be prepended to the ciphertext. func Decrypt(ciphertext []byte, key []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonceSize := gcm.NonceSize() if len(ciphertext) < nonceSize { return nil, ErrCiphertextTooShort } nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] return gcm.Open(nil, nonce, ciphertext, nil) }
Create **pkg/storage/crypto.go**:
```go
package storage
import (
"crypto/rand"
"golang.org/x/crypto/argon2"
)
const (
argon2Time uint32 = 1
argon2Memory uint32 = 64 * 1024 // 64 MB — RFC 9106 Section 7.3
argon2Threads uint8 = 4
argon2KeyLen uint32 = 32 // AES-256 key length
saltSize = 16
)
// DeriveKey produces a 32-byte AES-256 key from a passphrase and salt using Argon2id.
// Uses RFC 9106 Section 7.3 recommended parameters.
// Given the same passphrase and salt, always returns the same key.
func DeriveKey(passphrase []byte, salt []byte) []byte {
return argon2.IDKey(passphrase, salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
}
// NewSalt generates a cryptographically random 16-byte salt.
// Store alongside the database and reuse on each key derivation.
func NewSalt() ([]byte, error) {
salt := make([]byte, saltSize)
if _, err := rand.Read(salt); err != nil {
return nil, err
}
return salt, nil
}
CREATE TABLE IF NOT EXISTS scans ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at DATETIME NOT NULL, finished_at DATETIME, source_path TEXT, finding_count INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );
CREATE TABLE IF NOT EXISTS findings ( id INTEGER PRIMARY KEY AUTOINCREMENT, scan_id INTEGER REFERENCES scans(id), provider_name TEXT NOT NULL, key_value BLOB NOT NULL, key_masked TEXT NOT NULL, confidence TEXT NOT NULL, source_path TEXT, source_type TEXT, line_number INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );
CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP );
-- Indexes for common queries CREATE INDEX IF NOT EXISTS idx_findings_scan_id ON findings(scan_id); CREATE INDEX IF NOT EXISTS idx_findings_provider ON findings(provider_name); CREATE INDEX IF NOT EXISTS idx_findings_created ON findings(created_at DESC);
Create **pkg/storage/db.go**:
```go
package storage
import (
"database/sql"
_ "embed"
"fmt"
_ "modernc.org/sqlite"
)
//go:embed schema.sql
var schemaSQLBytes []byte
// DB wraps the sql.DB connection with KeyHunter-specific behavior.
type DB struct {
sql *sql.DB
}
// Open opens or creates a SQLite database at path, runs embedded schema migrations,
// and enables WAL mode for better concurrent read performance.
// Use ":memory:" for tests.
func Open(path string) (*DB, error) {
sqlDB, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
// Enable WAL mode for concurrent reads
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("enabling WAL mode: %w", err)
}
// Enable foreign keys
if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("enabling foreign keys: %w", err)
}
// Run schema migrations
if _, err := sqlDB.Exec(string(schemaSQLBytes)); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("running schema migrations: %w", err)
}
return &DB{sql: sqlDB}, nil
}
// Close closes the underlying database connection.
func (db *DB) Close() error {
return db.sql.Close()
}
// SQL returns the underlying sql.DB for advanced use cases.
func (db *DB) SQL() *sql.DB {
return db.sql
}
Create pkg/storage/findings.go:
package storage
import (
"fmt"
"time"
)
// Finding represents a detected API key with metadata.
// KeyValue is always plaintext in this struct — encryption happens at the storage boundary.
type Finding struct {
ID int64
ScanID int64
ProviderName string
KeyValue string // plaintext — encrypted before storage, decrypted after retrieval
KeyMasked string // first8...last4, stored plaintext
Confidence string
SourcePath string
SourceType string
LineNumber int
CreatedAt time.Time
}
// MaskKey returns the masked form of a key: first 8 chars + "..." + last 4 chars.
// If the key is too short (< 12 chars), returns the full key masked with asterisks.
func MaskKey(key string) string {
if len(key) < 12 {
return "****"
}
return key[:8] + "..." + key[len(key)-4:]
}
// SaveFinding encrypts the finding's KeyValue and persists the finding to the database.
// encKey must be a 32-byte AES-256 key (from DeriveKey).
func (db *DB) SaveFinding(f Finding, encKey []byte) (int64, error) {
encrypted, err := Encrypt([]byte(f.KeyValue), encKey)
if err != nil {
return 0, fmt.Errorf("encrypting key value: %w", err)
}
masked := f.KeyMasked
if masked == "" {
masked = MaskKey(f.KeyValue)
}
res, err := db.sql.Exec(
`INSERT INTO findings (scan_id, provider_name, key_value, key_masked, confidence, source_path, source_type, line_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
f.ScanID, f.ProviderName, encrypted, masked, f.Confidence, f.SourcePath, f.SourceType, f.LineNumber,
)
if err != nil {
return 0, fmt.Errorf("inserting finding: %w", err)
}
return res.LastInsertId()
}
// ListFindings retrieves all findings, decrypting key values using encKey.
// encKey must be the same 32-byte key used during SaveFinding.
func (db *DB) ListFindings(encKey []byte) ([]Finding, error) {
rows, err := db.sql.Query(
`SELECT id, scan_id, provider_name, key_value, key_masked, confidence,
source_path, source_type, line_number, created_at
FROM findings ORDER BY created_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("querying findings: %w", err)
}
defer rows.Close()
var findings []Finding
for rows.Next() {
var f Finding
var encrypted []byte
var createdAt string
err := rows.Scan(
&f.ID, &f.ScanID, &f.ProviderName, &encrypted, &f.KeyMasked,
&f.Confidence, &f.SourcePath, &f.SourceType, &f.LineNumber, &createdAt,
)
if err != nil {
return nil, fmt.Errorf("scanning finding row: %w", err)
}
plain, err := Decrypt(encrypted, encKey)
if err != nil {
return nil, fmt.Errorf("decrypting finding %d: %w", f.ID, err)
}
f.KeyValue = string(plain)
f.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
findings = append(findings, f)
}
return findings, rows.Err()
}
Fill pkg/storage/db_test.go (replacing stubs from Plan 01):
package storage_test
import (
"testing"
"github.com/salvacybersec/keyhunter/pkg/storage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDBOpen(t *testing.T) {
db, err := storage.Open(":memory:")
require.NoError(t, err)
defer db.Close()
// Verify schema tables exist
rows, err := db.SQL().Query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
require.NoError(t, err)
defer rows.Close()
var tables []string
for rows.Next() {
var name string
require.NoError(t, rows.Scan(&name))
tables = append(tables, name)
}
assert.Contains(t, tables, "findings")
assert.Contains(t, tables, "scans")
assert.Contains(t, tables, "settings")
}
func TestEncryptDecryptRoundtrip(t *testing.T) {
key := make([]byte, 32) // all-zero key for test
for i := range key {
key[i] = byte(i)
}
plaintext := []byte("sk-proj-supersecretapikey1234")
ciphertext, err := storage.Encrypt(plaintext, key)
require.NoError(t, err)
assert.Greater(t, len(ciphertext), len(plaintext), "ciphertext should be longer than plaintext")
recovered, err := storage.Decrypt(ciphertext, key)
require.NoError(t, err)
assert.Equal(t, plaintext, recovered)
}
func TestEncryptNonDeterministic(t *testing.T) {
key := make([]byte, 32)
plain := []byte("test-key")
ct1, err1 := storage.Encrypt(plain, key)
ct2, err2 := storage.Encrypt(plain, key)
require.NoError(t, err1)
require.NoError(t, err2)
assert.NotEqual(t, ct1, ct2, "same plaintext encrypted twice should produce different ciphertext")
}
func TestDecryptWrongKey(t *testing.T) {
key1 := make([]byte, 32)
key2 := make([]byte, 32)
key2[0] = 0xFF
ct, err := storage.Encrypt([]byte("secret"), key1)
require.NoError(t, err)
_, err = storage.Decrypt(ct, key2)
assert.Error(t, err, "decryption with wrong key should fail")
}
func TestArgon2KeyDerivation(t *testing.T) {
passphrase := []byte("my-secure-passphrase")
salt := []byte("1234567890abcdef") // 16 bytes
key1 := storage.DeriveKey(passphrase, salt)
key2 := storage.DeriveKey(passphrase, salt)
assert.Equal(t, 32, len(key1), "derived key must be 32 bytes")
assert.Equal(t, key1, key2, "same passphrase+salt must produce same key")
}
func TestNewSalt(t *testing.T) {
salt1, err1 := storage.NewSalt()
salt2, err2 := storage.NewSalt()
require.NoError(t, err1)
require.NoError(t, err2)
assert.Equal(t, 16, len(salt1))
assert.NotEqual(t, salt1, salt2, "two salts should differ")
}
func TestSaveFindingEncrypted(t *testing.T) {
db, err := storage.Open(":memory:")
require.NoError(t, err)
defer db.Close()
// Derive a test key
key := storage.DeriveKey([]byte("testpassphrase"), []byte("testsalt1234xxxx"))
f := storage.Finding{
ProviderName: "openai",
KeyValue: "sk-proj-test1234567890abcdefghijklmnopqr",
Confidence: "high",
SourcePath: "/test/file.env",
SourceType: "file",
LineNumber: 42,
}
id, err := db.SaveFinding(f, key)
require.NoError(t, err)
assert.Greater(t, id, int64(0))
findings, err := db.ListFindings(key)
require.NoError(t, err)
require.Len(t, findings, 1)
assert.Equal(t, "sk-proj-test1234567890abcdefghijklmnopqr", findings[0].KeyValue)
assert.Equal(t, "openai", findings[0].ProviderName)
// Verify masking
assert.Equal(t, "sk-proj-...opqr", findings[0].KeyMasked)
}
<success_criteria>
- SQLite database opens and auto-migrates from embedded schema.sql (STOR-01)
- AES-256-GCM column encryption works: Encrypt + Decrypt roundtrip returns original (STOR-02)
- Argon2id key derivation: DeriveKey deterministic, 32 bytes, RFC 9106 params (STOR-03)
- FindingCRUD: SaveFinding encrypts before INSERT, ListFindings decrypts after SELECT
- All 7 storage tests pass </success_criteria>