Skip to main content

Encryption Details

This guide provides detailed information about the cryptographic algorithms, parameters, and implementation used in secretctl.

Cryptographic Specifications

Summary

ComponentAlgorithmParameters
Symmetric encryptionAES-256-GCM256-bit key, 96-bit nonce
Key derivationArgon2id64MB memory, 3 iterations, 4 threads
SaltRandom128-bit (16 bytes)
NonceRandom96-bit (12 bytes) per encryption
HMAC (audit logs)HMAC-SHA256256-bit key

Standards Compliance

SpecificationReference
AES-GCMNIST SP 800-38D
Argon2RFC 9106
OWASPPassword Storage Cheat Sheet
Key derivationHKDF (RFC 5869)

AES-256-GCM

Why AES-256-GCM?

PropertyBenefit
Authenticated encryptionDetects tampering automatically
NIST recommendedGovernment and industry standard
Hardware accelerationFast on modern CPUs (AES-NI)
Well-studiedDecades of cryptanalysis

Implementation

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
)

func encrypt(plaintext, 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
}

// Generate random nonce
nonce := make([]byte, gcm.NonceSize()) // 12 bytes
if _, err := rand.Read(nonce); err != nil {
return nil, err
}

// Encrypt and authenticate
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}

func decrypt(ciphertext, 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, errors.New("ciphertext too short")
}

nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
return gcm.Open(nil, nonce, ciphertext, nil)
}

Blob Format

All encrypted data uses a consistent format:

┌──────────────┬─────────────────────┬────────────────┐
│ Nonce (12B) │ Ciphertext │ GCM Tag (16B) │
└──────────────┴─────────────────────┴────────────────┘

Applies to:

  • encrypted_dek (vault_keys table)
  • encrypted_key (secrets table)
  • encrypted_value (secrets table)
  • encrypted_metadata (secrets table)

Benefits of This Format

BenefitDescription
Self-containedEach blob can be decrypted independently
No separate nonce columnSimpler database schema
Industry standardSame as libsodium sealed boxes

Argon2id Key Derivation

Why Argon2id?

PropertyBenefit
Memory-hardExpensive for GPUs/ASICs
Time-hardMultiple iterations required
ParallelismUtilizes multiple cores
RFC standardRFC 9106 (2021)

Argon2id combines Argon2i (side-channel resistant) and Argon2d (GPU resistant).

Parameters

import "golang.org/x/crypto/argon2"

const (
memory = 64 * 1024 // 64 MB
iterations = 3
parallelism = 4
keyLength = 32 // 256 bits
saltLength = 16 // 128 bits
)

func deriveKey(password string, salt []byte) []byte {
return argon2.IDKey(
[]byte(password),
salt,
iterations,
memory,
parallelism,
keyLength,
)
}

OWASP Compliance

These parameters follow OWASP Password Storage Cheat Sheet:

ParameterValueOWASP Recommendation
Memory64 MB64 MB minimum
Time33 iterations
Threads44 (modern multi-core)

Performance by Environment

EnvironmentMemoryUnlock TimeStatus
Modern PC/Mac8GB+0.5-1 secOptimal
Low-spec laptop4GB1-2 secGood
Raspberry Pi 42-8GB1-3 secAcceptable
CI environment2-4GB1-2 secGood
Docker (limited)VariesVaries64MB+ required
Docker Memory

Containers with less than 64MB memory will fail during key derivation. Ensure your container has sufficient memory allocated.

Nonce Management

Uniqueness Guarantee

AES-GCM requires unique nonces. Reusing a nonce with the same key compromises security (Forbidden Attack).

Strategy: Random Nonce

func generateNonce() ([]byte, error) {
nonce := make([]byte, 12) // 96 bits
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
return nonce, nil
}

Collision Probability

Nonce LengthCollision AfterProbability
96-bit2^32 encryptions2^-33 ≈ 1 in 8.6 billion

For personal use, reaching 4 billion encryptions is practically impossible.

Randomness Source

import "crypto/rand"

Go's crypto/rand uses:

  • Linux: /dev/urandom (CSPRNG)
  • macOS: getentropy() (kernel entropy)
  • Windows: CryptGenRandom() (CryptoAPI)

All are cryptographically secure pseudo-random number generators.

Key Hierarchy Details

Three-Tier Structure

┌─────────────────────────────────────────────────────────────────────┐
│ Key Hierarchy │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Tier 1: Master Password (user input) │
│ │ │
│ │ Never stored, used only for derivation │
│ │ │
│ ▼ │
│ Tier 2: Master Key (derived) │
│ │ │
│ │ = Argon2id(password, salt) │
│ │ Lives in memory only during session │
│ │ │
│ ▼ │
│ Tier 3: Data Encryption Key (DEK) │
│ │ │
│ │ Stored as: AES-GCM(DEK, MasterKey) │
│ │ Used to encrypt all secrets │
│ │ │
│ ▼ │
│ Secrets: AES-GCM(secret, DEK) │
│ │
└─────────────────────────────────────────────────────────────────────┘

Why This Design?

FeatureBenefit
Password rotationChange password without re-encrypting all secrets
Defense in depthCompromise of one layer doesn't expose everything
Session isolationMaster key cleared after lock

Password Rotation Flow

1. Unlock vault (derive old master key)
2. Decrypt DEK with old master key
3. Generate new salt
4. Derive new master key from new password
5. Re-encrypt DEK with new master key
6. Store new encrypted DEK and salt
7. Secrets remain unchanged (still encrypted with same DEK)

Database Schema

Tables

-- Encrypted DEK storage
CREATE TABLE vault_keys (
id INTEGER PRIMARY KEY,
encrypted_dek BLOB NOT NULL, -- nonce || AES-GCM ciphertext
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Secrets storage
CREATE TABLE secrets (
id INTEGER PRIMARY KEY,
key_hash TEXT UNIQUE NOT NULL, -- SHA-256(key) for lookup
encrypted_key BLOB NOT NULL, -- nonce || encrypted key name
encrypted_value BLOB NOT NULL, -- nonce || encrypted value
encrypted_metadata BLOB, -- nonce || encrypted JSON (optional)
tags TEXT, -- comma-separated, plaintext
expires_at TIMESTAMP, -- plaintext for queries
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Audit log with HMAC chain
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY,
action TEXT NOT NULL, -- get, set, delete, list
key_hash TEXT, -- SHA-256 of accessed key
source TEXT NOT NULL, -- cli, mcp, ui
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
prev_hash TEXT NOT NULL, -- previous record hash
record_hash TEXT NOT NULL -- HMAC of this record
);

Why key_hash?

Storing SHA-256(key) instead of the plaintext key name:

  • Allows lookups without decryption
  • Protects key names at rest
  • One-way (can't derive key name from hash)

Metadata Encryption

Structure

type SecretMetadata struct {
Version int `json:"v"` // Schema version (1)
Notes string `json:"notes,omitempty"` // Max 10KB
URL string `json:"url,omitempty"` // Max 2048 chars
}

Storage Rules

Conditionencrypted_metadata
notes="" AND url=""NULL (no encryption)
notes OR url has valuenonce + AES-GCM(JSON)

MCP Access Restrictions

AI-Safe Access extends to metadata:

DataMCP Access
key (name)Yes (via secret_list)
tagsYes (plaintext, for search)
expires_atYes (plaintext, for queries)
has_notesYes (boolean flag only)
has_urlYes (boolean flag only)
notes contentNo
url contentNo
valueNo

Rule: Encrypted columns = MCP access prohibited.

Audit Log Integrity

HMAC Chain

// Derive audit key from master key
auditKey := hkdf.Expand(sha256.New, masterKey, []byte("audit-log-v1"), 32)

// Compute record HMAC
func computeRecordHMAC(record AuditRecord, prevHash string, key []byte) string {
data := fmt.Sprintf("%d|%s|%s|%s|%s|%s",
record.ID,
record.Action,
record.KeyHash,
record.Source,
record.Timestamp.Format(time.RFC3339Nano),
prevHash,
)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(data))
return hex.EncodeToString(mac.Sum(nil))
}

Verification

func verifyChain(records []AuditRecord, key []byte) error {
for i, r := range records {
// Verify HMAC
var prevHash string
if i > 0 {
prevHash = records[i-1].RecordHash
}
expected := computeRecordHMAC(r, prevHash, key)
if r.RecordHash != expected {
return fmt.Errorf("record %d: HMAC mismatch", r.ID)
}

// Verify chain
if i > 0 && r.PrevHash != records[i-1].RecordHash {
return fmt.Errorf("record %d: chain broken", r.ID)
}
}
return nil
}

Libraries Used

Go Standard Library

import (
"crypto/aes" // AES encryption
"crypto/cipher" // GCM mode
"crypto/hmac" // HMAC
"crypto/rand" // Secure random
"crypto/sha256" // SHA-256 hashing
)

golang.org/x/crypto

import (
"golang.org/x/crypto/argon2" // Key derivation
"golang.org/x/crypto/hkdf" // Key expansion
)

Why These Libraries?

LibraryReason
Go standard libraryBattle-tested, audited, maintained
golang.org/x/cryptoOfficial Go cryptography extensions

No third-party cryptography libraries are used, minimizing supply chain risk.

Security Considerations

What's Protected

AssetProtection
Secret valuesAES-256-GCM encryption
Key namesAES-256-GCM encryption
MetadataAES-256-GCM encryption
Master passwordNever stored
Audit integrityHMAC chain

What's NOT Protected

AssetReason
TagsStored plaintext for search
Expiration datesStored plaintext for queries
Record countsObservable from DB
Access patternsProtected by audit log

Backup Considerations

# Safe to backup (encrypted)
~/.secretctl/vault.db
~/.secretctl/vault.salt
~/.secretctl/vault.meta

# Required for restore
# - All three files above
# - Master password (not stored)
Lost Password

If you forget your master password, your secrets cannot be recovered. This is by design - there are no backdoors.

Next Steps