---
phase: 01-foundation
plan: 03
type: execute
wave: 1
depends_on: [01-01]
files_modified:
  - pkg/storage/schema.sql
  - pkg/storage/encrypt.go
  - pkg/storage/crypto.go
  - pkg/storage/db.go
  - pkg/storage/findings.go
  - pkg/storage/db_test.go
autonomous: true
requirements: [STOR-01, STOR-02, STOR-03]

must_haves:
  truths:
    - "SQLite database opens, runs migrations from embedded schema.sql, and closes cleanly"
    - "AES-256-GCM Encrypt/Decrypt roundtrip produces the original plaintext"
    - "Argon2id DeriveKey with the same passphrase and salt always returns the same 32-byte key"
    - "A Finding can be saved to the database with the key_value stored encrypted and retrieved as plaintext"
    - "The raw database file does NOT contain plaintext API key values — verified by querying raw bytes from the BLOB column"
  artifacts:
    - path: "pkg/storage/encrypt.go"
      provides: "Encrypt(plaintext, key) and Decrypt(ciphertext, key) using AES-256-GCM"
      exports: ["Encrypt", "Decrypt"]
    - path: "pkg/storage/crypto.go"
      provides: "DeriveKey(passphrase, salt) using Argon2id RFC 9106 params"
      exports: ["DeriveKey", "NewSalt"]
    - path: "pkg/storage/db.go"
      provides: "DB struct with Open(), Close(), WAL mode, embedded schema migration"
      exports: ["DB", "Open"]
    - path: "pkg/storage/findings.go"
      provides: "SaveFinding(finding, encKey) and ListFindings(encKey) CRUD"
      exports: ["SaveFinding", "ListFindings", "Finding"]
    - path: "pkg/storage/schema.sql"
      provides: "CREATE TABLE statements for findings, scans, settings"
      contains: "CREATE TABLE IF NOT EXISTS findings"
  key_links:
    - from: "pkg/storage/findings.go"
      to: "pkg/storage/encrypt.go"
      via: "Encrypt() called before INSERT, Decrypt() called after SELECT"
      pattern: "Encrypt|Decrypt"
    - from: "pkg/storage/db.go"
      to: "pkg/storage/schema.sql"
      via: "//go:embed schema.sql and db.Exec on open"
      pattern: "go:embed.*schema"
    - from: "pkg/storage/crypto.go"
      to: "golang.org/x/crypto/argon2"
      via: "argon2.IDKey call"
      pattern: "argon2\\.IDKey"
---

<objective>
Build the storage layer: AES-256-GCM column encryption, Argon2id key derivation, SQLite database with WAL mode and embedded schema, and Finding CRUD operations that transparently encrypt key values on write and decrypt on read.

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).
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/01-foundation/01-RESEARCH.md
@.planning/phases/01-foundation/01-01-SUMMARY.md

<interfaces>
<!-- AES-256-GCM encrypt/decrypt pattern from RESEARCH.md Pattern 3 -->
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 GCM

func Decrypt(ciphertext []byte, key []byte) ([]byte, error)
  // expects nonce prepended format from Encrypt()
  // returns ErrCiphertextTooShort if len < nonceSize

<!-- Argon2id key derivation pattern from RESEARCH.md Pattern 4 -->
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

<!-- SQLite schema — findings table -->
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

<!-- Finding struct for inter-package communication -->
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
}

<!-- DB driver registration -->
import _ "modernc.org/sqlite"
// driver registered as "sqlite" (NOT "sqlite3")
db, err := sql.Open("sqlite", dataSourceName)
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: AES-256-GCM encryption and Argon2id key derivation</name>
  <files>pkg/storage/encrypt.go, pkg/storage/crypto.go</files>
  <read_first>
    - /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)
  </read_first>
  <behavior>
    - 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
  </behavior>
  <action>
Create **pkg/storage/encrypt.go**:
```go
package storage

import (
    "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
}
```
  </action>
  <verify>
    <automated>cd /home/salva/Documents/apikey && go build ./pkg/storage/... && echo "BUILD OK"</automated>
  </verify>
  <acceptance_criteria>
    - `go build ./pkg/storage/...` exits 0
    - pkg/storage/encrypt.go exports: Encrypt, Decrypt, ErrCiphertextTooShort
    - pkg/storage/crypto.go exports: DeriveKey, NewSalt
    - `grep -q 'argon2\.IDKey' pkg/storage/crypto.go` exits 0
    - `grep -q 'crypto/aes' pkg/storage/encrypt.go` exits 0
    - `grep -q 'cipher\.NewGCM' pkg/storage/encrypt.go` exits 0
  </acceptance_criteria>
  <done>Encrypt/Decrypt and DeriveKey/NewSalt exist and compile. Encryption uses AES-256-GCM with random nonce. Key derivation uses Argon2id RFC 9106 parameters.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: SQLite database, schema, Finding CRUD, and filled test stubs</name>
  <files>pkg/storage/schema.sql, pkg/storage/db.go, pkg/storage/findings.go, pkg/storage/db_test.go</files>
  <read_first>
    - /home/salva/Documents/apikey/.planning/phases/01-foundation/01-RESEARCH.md (STOR-01 row, Pattern 1 for embed usage pattern)
    - /home/salva/Documents/apikey/pkg/storage/encrypt.go (Encrypt/Decrypt signatures)
    - /home/salva/Documents/apikey/pkg/storage/crypto.go (DeriveKey signature)
  </read_first>
  <behavior>
    - Test 1: Open(":memory:") returns *DB without error, schema tables exist
    - Test 2: Encrypt/Decrypt roundtrip — Encrypt([]byte("sk-proj-abc"), key) then Decrypt returns "sk-proj-abc"
    - Test 3: DeriveKey(passphrase, salt) twice returns identical 32 bytes
    - Test 4: NewSalt() twice returns different slices
    - Test 5: SaveFinding stores finding → ListFindings decrypts and returns KeyValue == "sk-proj-test"
    - Test 6: Raw BLOB bytes retrieved directly from the database do NOT contain the plaintext key string
  </behavior>
  <action>
Create **pkg/storage/schema.sql**:
```sql
-- KeyHunter database schema
-- Version: 1

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**:
```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):
```go
package storage_test

import (
    "bytes"
    "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"))

    plainKey := "sk-proj-test1234567890abcdefghijklmnopqr"
    f := storage.Finding{
        ProviderName: "openai",
        KeyValue:     plainKey,
        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, plainKey, findings[0].KeyValue)
    assert.Equal(t, "openai", findings[0].ProviderName)
    // Verify masking
    assert.Equal(t, "sk-proj-...opqr", findings[0].KeyMasked)

    // Verify encryption contract: raw BLOB bytes in the database must NOT contain the plaintext key.
    // This confirms Encrypt() was called before INSERT, not that the key was stored verbatim.
    var rawBlob []byte
    require.NoError(t, db.SQL().QueryRow("SELECT key_value FROM findings WHERE id = ?", id).Scan(&rawBlob))
    assert.False(t, bytes.Contains(rawBlob, []byte(plainKey)),
        "raw database BLOB must not contain plaintext key — encryption was not applied")
}
```
  </action>
  <verify>
    <automated>cd /home/salva/Documents/apikey && go test ./pkg/storage/... -v -count=1 2>&1 | tail -25</automated>
  </verify>
  <acceptance_criteria>
    - `go test ./pkg/storage/... -v -count=1` exits 0 with all 7 tests PASS (no SKIP)
    - TestDBOpen finds tables: findings, scans, settings
    - TestEncryptDecryptRoundtrip passes — recovered plaintext matches original
    - TestEncryptNonDeterministic passes — two encryptions differ
    - TestDecryptWrongKey passes — wrong key causes error
    - TestArgon2KeyDerivation passes — 32 bytes, deterministic
    - TestNewSalt passes — 16 bytes, non-deterministic
    - TestSaveFindingEncrypted passes — stored and retrieved with correct KeyValue, KeyMasked, AND raw BLOB does not contain plaintext
    - `grep -q 'go:embed.*schema' pkg/storage/db.go` exits 0
    - `grep -q 'modernc.org/sqlite' pkg/storage/db.go` exits 0
    - `grep -q 'journal_mode=WAL' pkg/storage/db.go` exits 0
  </acceptance_criteria>
  <done>Storage layer complete — SQLite opens with schema, AES-256-GCM encrypt/decrypt works, Argon2id key derivation works, SaveFinding/ListFindings encrypt/decrypt transparently. Raw BLOB bytes verified to not contain plaintext. All 7 tests pass.</done>
</task>

</tasks>

<verification>
After both tasks:
- `go test ./pkg/storage/... -v -count=1` exits 0 with 7 tests PASS
- `go build ./...` still exits 0
- `grep -q 'argon2\.IDKey' pkg/storage/crypto.go` exits 0
- `grep -q 'cipher\.NewGCM' pkg/storage/encrypt.go` exits 0
- `grep -q 'journal_mode=WAL' pkg/storage/db.go` exits 0
- schema.sql contains CREATE TABLE for findings, scans, settings
- TestSaveFindingEncrypted asserts raw BLOB does not contain plaintext key
</verification>

<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
- Raw BLOB in database does not contain plaintext key — verified by automated test
- All 7 storage tests pass
</success_criteria>

<output>
After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md` following the summary template.
</output>
