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​