Skip to main content

How Security Works

This guide explains the security architecture of secretctl, including how secrets are protected, how different access methods work, and the multi-layer defense model.

Architecture Overview

┌─────────────────────────────────────────────────────────────────────┐
│ secretctl Architecture │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Human Operators AI Agents │
│ ═══════════════ ═════════ │
│ CLI / Desktop App MCP Server │
│ │ │ │
│ │ Full Access │ Restricted (AI-Safe Access) │
│ │ │ │
│ └──────────┬────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Vault │ │
│ │ (SQLite) │ │
│ │ │ │
│ │ AES-256-GCM │ │
│ │ Encrypted │ │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Audit Log │ │
│ │ HMAC Chain │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

Key Hierarchy

secretctl uses a three-tier key hierarchy for defense in depth:

┌─────────────────────────────────────────────────────────┐
│ User Input │
│ Master Password │
└─────────────────────┬───────────────────────────────────┘

▼ Argon2id (memory: 64MB, time: 3, threads: 4)
┌─────────────────────────────────────────────────────────┐
│ Master Key (256-bit) │
│ ※ Memory only, never stored │
└─────────────────────┬───────────────────────────────────┘

▼ AES-256-GCM encryption
┌─────────────────────────────────────────────────────────┐
│ Data Encryption Key (DEK) (256-bit) │
│ ※ Stored encrypted │
└─────────────────────┬───────────────────────────────────┘

▼ AES-256-GCM encryption
┌─────────────────────────────────────────────────────────┐
│ Encrypted Secrets │
│ ※ Stored in SQLite │
└─────────────────────────────────────────────────────────┘

Why This Design?

LayerPurpose
Master PasswordUser authentication, never stored
Master KeyDerived fresh each unlock, protects DEK
DEKActual encryption key, allows password rotation
Encrypted SecretsProtected data at rest

Vault Structure

The vault directory contains:

~/.secretctl/
├── vault.db # SQLite DB (values encrypted by app)
├── vault.salt # Argon2 salt (128-bit)
└── vault.meta # Metadata (version, created_at)

File Permissions

All sensitive files use strict permissions:

FilePermissionReason
vault.db0600Contains encrypted secrets
vault.salt0600Required for key derivation
vault.meta0600Metadata protection
~/.secretctl/0700Directory access control

Access Control Model

CLI vs MCP: Trust Levels

secretctl restricts functionality based on access method:

┌─────────────────────────────────────────────────────────────────────┐
│ secretctl Access Model │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ CLI (Human Operator) MCP Server (AI Agent) │
│ ════════════════════ ═════════════════════ │
│ Trust Level: FULL Trust Level: RESTRICTED │
│ │
│ ✅ secretctl get KEY ❌ secret_get (not implemented)│
│ → Plaintext OK → No plaintext to AI │
│ │
│ ✅ secretctl set KEY ❌ secret_set (not implemented)│
│ → Store via stdin → AI cannot set values │
│ │
│ ✅ secretctl list ✅ secret_list │
│ → Full key list → Key names only │
│ │
│ ✅ secretctl run -- cmd ✅ secret_run │
│ → Environment injection → Masked output only │
│ │
│ ✅ secretctl delete KEY ❌ (not implemented) │
│ → Delete OK → AI cannot delete │
│ │
│ ✅ secretctl export ❌ (not implemented) │
│ → Export OK → AI cannot export │
│ │
└─────────────────────────────────────────────────────────────────────┘

Design Rationale

AccessOperatorTrustRestrictions
CLIHuman directlyFullNone (all features)
DesktopHuman directlyFullNone (all features)
MCPAI agentRestrictedNo plaintext get/set/delete

Why the difference?

  • CLI users are humans who own their secrets and have the right to see them
  • AI agents are proxies that don't need to know the actual values
  • AI only needs the result of using secrets, not the secrets themselves

AI-Safe Access: Access Without Exposure

The core security model for AI integration:

┌─────────────────────────────────────────────────────────────────────┐
│ AI-Safe Access: Access Without Exposure │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ Traditional MCP Implementation: │
│ AI → MCP Server → Plaintext Secret → AI → Use │
│ Problem: AI "knows" the secret │
│ │
│ ✅ secretctl AI-Safe Access: │
│ AI → "Run this command with this secret" │
│ → Secret injected directly (AI never sees it) │
│ → Only the result returns to AI │
│ │
└─────────────────────────────────────────────────────────────────────┘

MCP Tool Capabilities

CapabilityDescriptionAI Response
reference_onlyKey existence check{"exists": true, "created_at": "..."}
env_injectEnvironment injection{"exit_code": 0, "stdout": "..."}
masked_returnMasked value display{"masked_value": "sk-****7890"}

Note: full (plaintext return) is abolished. No MCP tool returns plaintext values.

secret_run Security Layers

The secret_run command uses multi-layer defense:

AI → secret_run(keys, command)


┌─────────────────────────────────────────────────┐
│ Layer 1: Command Validation │
│ - Blocked commands list (env, printenv, etc.) │
│ - Additional AI restrictions │
├─────────────────────────────────────────────────┤
│ Layer 2: Execution Environment │
│ - Non-TTY mode forced │
│ - Temporary working directory │
│ - Timeout: 300 seconds │
├─────────────────────────────────────────────────┤
│ Layer 3: Environment Injection │
│ - Secrets exist only in process environment │
│ - Values never passed to AI │
├─────────────────────────────────────────────────┤
│ Layer 4: Output Sanitization │
│ - Scan stdout/stderr for secrets │
│ - Mask any detected values │
│ - Base64 encoding detection │
└─────────────────────────────────────────────────┘


AI ← {"exit_code": 0, "stdout": "...", "stderr": "..."}
※ Secret values never included

Blocked Commands

Commands that could expose environment variables:

var blockedCommands = []string{
"env",
"printenv",
"set",
"export",
"declare",
"cat /proc/*/environ",
}

Output Sanitization

// OutputSanitizer masks secrets in stdout/stderr
type OutputSanitizer struct {
secrets map[string]string // key -> value
}

func (s *OutputSanitizer) Sanitize(output string) string {
result := output
for key, value := range s.secrets {
// Plaintext detection
if strings.Contains(result, value) {
result = strings.ReplaceAll(result, value, "[REDACTED:"+key+"]")
}
// Base64 encoding detection
encoded := base64.StdEncoding.EncodeToString([]byte(value))
if strings.Contains(result, encoded) {
result = strings.ReplaceAll(result, encoded, "[REDACTED:"+key+":base64]")
}
}
return result
}

Masking Format

Values are masked with fixed-length prefix for security:

func maskValue(value string) string {
if len(value) <= 8 {
return "********"
}
// Show only last 4 characters
return "********" + value[len(value)-4:]
}

// Example: "sk-proj-abc123xyz789" → "********z789"

Fixed-length masking prevents value length inference.

Audit Logging

HMAC Chain Integrity

┌─────────────────────────────────────────────────────────────────────┐
│ Audit Log HMAC Chain │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Record 1 Record 2 Record 3 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ data │ │ data │ │ data │ │
│ │ prev: Ø │──┐ │ prev: H1│──┐ │ prev: H2│ │
│ │ hash: H1│ │ │ hash: H2│ │ │ hash: H3│ │
│ └─────────┘ │ └─────────┘ │ └─────────┘ │
│ │ ▲ │ ▲ │
│ └─────────┘ └─────────┘ │
│ │
│ H = HMAC-SHA256(id || action || key_hash || ... || prev, key) │
│ key = HKDF-SHA256(master_key, "audit-log-v1") │
│ │
│ Tampering Detection: prev_hash mismatch → chain broken │
│ │
└─────────────────────────────────────────────────────────────────────┘

What's Logged

Recorded:

  • Operation type (get, set, delete, list, run)
  • Key hash (not the key name itself for sensitive operations)
  • Source (cli, mcp, ui)
  • Timestamp (nanosecond precision)
  • Result (success, denied, error)

NOT Recorded (for security):

  • Secret values (plaintext or encrypted)
  • Master password
  • Raw user input

Verification

# Verify audit log integrity
$ secretctl audit verify
15,234 records verified
✓ Chain integrity: OK
✓ No gaps detected

# If tampering detected
$ secretctl audit verify
✗ Chain broken at record id=01HQ5E7N8K...
Expected prev: a3f2b1...
Actual prev: 7c8d4e...
ALERT: Possible tampering detected

MCP Policy Engine

Policy Configuration

# ~/.secretctl/mcp-policy.yaml
policies:
- name: "claude-desktop"
agent_id: "claude-desktop-*"
allowed_keys:
- "api/*" # Allow api/ prefix
- "config/dev/*"
denied_keys:
- "*/prod/*" # Deny prod secrets
capabilities:
- env_inject # Environment injection
- reference_only # Existence check
- masked_return # Masked display

- name: "untrusted-agent"
agent_id: "*"
allowed_keys: [] # Deny all
capabilities:
- reference_only # Only check existence

Policy Evaluation Order

  1. Check denied_keys first (deny takes precedence)
  2. Check allowed_keys for permission
  3. Verify required capabilities
  4. Log access attempt to audit

Security Benefits

  1. No AI Plaintext Exposure

    • Most important protection
    • Values never in AI model logs or training data
  2. Prompt Injection Resistance

    • No secret values to extract via malicious prompts
    • Even if AI is compromised, secrets remain protected
  3. Multi-Layer Defense

    • Command validation + environment isolation + output filtering
    • Defense in depth approach
  4. Full Auditability

    • Every command logged with tamper detection
    • Alerts when sanitization triggers
  5. Least Privilege

    • AI only gets minimum necessary information
    • Key names and masked values only

Next Steps