This document describes the security architecture, cryptographic design, threat model, and security properties of envctl.
envctl is designed with a zero-infrastructure security model where all cryptographic operations happen locally or peer-to-peer, eliminating central points of compromise. The system uses post-quantum cryptography to protect against future quantum computer attacks while maintaining strong classical security guarantees.
graph TB
subgraph "Security Layers"
L1[Identity Protection<br/>Argon2id + AES-256-GCM]
L2[Secret Encryption<br/>ML-KEM-768 + AES-256-GCM]
L3[Transport Security<br/>TLS 1.3 + mTLS]
L4[Access Control<br/>Blockchain + Signatures]
end
L1 --> L2 --> L3 --> L4
| Purpose | Algorithm | Security Level | Standard |
|---|---|---|---|
| Key Encapsulation | ML-KEM-768 | 192-bit PQ | FIPS 203 |
| Digital Signatures | Ed25519 | 128-bit | RFC 8032 |
| PQ Signatures | ML-DSA-65 | 192-bit PQ | FIPS 204 |
| Symmetric Encryption | AES-256-GCM | 256-bit | NIST SP 800-38D |
| Key Derivation (password) | Argon2id | - | RFC 9106 |
| Key Derivation (keys) | HKDF-SHA256 | 256-bit | RFC 5869 |
| Hybrid Key Exchange | P-256 ECDH | 128-bit | FIPS 186-4 |
envctl uses NIST-standardized post-quantum algorithms to protect against future quantum computer attacks:
ML-KEM-768 (Key Encapsulation)
- Used for all asymmetric encryption of secrets
- Provides IND-CCA2 security against quantum adversaries
- 1088-byte ciphertext overhead
- Protects long-term confidentiality of stored secrets
ML-DSA-65 (Digital Signatures)
- Available for post-quantum signature operations
- Used in hybrid identity mode with YubiKey
- 3309-byte signatures, 1952-byte public keys
- Provides non-repudiation against quantum adversaries
sequenceDiagram
participant Sender
participant Recipient
Note over Sender: Has recipient's ML-KEM public key
Sender->>Sender: 1. Encapsulate shared secret<br/>(ML-KEM-768)
Sender->>Sender: 2. Derive AES key<br/>(HKDF-SHA256)
Sender->>Sender: 3. Generate random nonce<br/>(12 bytes)
Sender->>Sender: 4. Encrypt payload<br/>(AES-256-GCM)
Sender->>Recipient: [ciphertext || nonce || encrypted_data || tag]
Note over Recipient: Has ML-KEM private key
Recipient->>Recipient: 1. Decapsulate shared secret
Recipient->>Recipient: 2. Derive AES key
Recipient->>Recipient: 3. Verify tag & decrypt
All chain operations require Ed25519 signatures:
graph LR
subgraph "Block Signing"
Content[Block Content] --> Hash[SHA-256 Hash]
Hash --> Sign[Ed25519 Sign]
Sign --> Block[Signed Block]
end
subgraph "Verification"
Block --> Extract[Extract Signature]
Extract --> Verify[Ed25519 Verify]
Verify --> Accept[Accept/Reject]
end
Software identities are protected with strong password-based encryption:
graph TB
subgraph "Identity Encryption"
Pass[Passphrase] --> Argon[Argon2id<br/>128 MiB, 4 iter]
Salt[Random Salt<br/>16 bytes] --> Argon
Argon --> Key[256-bit Key]
Key --> AES[AES-256-GCM]
Identity[Private Keys] --> AES
AES --> File[identity.enc]
end
Argon2id Parameters:
- Memory: 128 MiB (exceeds OWASP minimum of 19 MiB)
- Iterations: 4 (exceeds OWASP minimum of 2)
- Parallelism: 4 threads
- Output: 32 bytes (256 bits)
- Salt: 16 bytes random per identity
For maximum security, identities can be stored on YubiKey:
graph TB
subgraph "YubiKey Storage"
PIV[PIV Applet]
PIV --> Slot9a[Slot 9a<br/>P-256 Signing]
PIV --> Slot9d[Slot 9d<br/>P-256 Key Management]
end
subgraph "PQC Keys"
YK[YubiKey ECDH] --> Wrap[Derive Wrap Key]
Wrap --> Encrypt[AES-256-GCM]
MLDSA[ML-DSA-65 Seed] --> Encrypt
MLKEM[ML-KEM-768 Seed] --> Encrypt
Encrypt --> Bundle[Encrypted Bundle]
end
Slot9d --> YK
Security Properties:
- P-256 private keys never leave YubiKey hardware
- Touch required for each cryptographic operation
- PIN protection (6-8 digits)
- PQC keys encrypted with YubiKey-derived secret
- Hybrid signatures require both YubiKey + PQC verification
The system keychain can store the passphrase for convenience:
| Platform | Backend |
|---|---|
| macOS | Keychain Services |
| Linux | Secret Service API (GNOME Keyring, KWallet) |
| Windows | Credential Manager |
The daemon attempts keychain retrieval before prompting for passphrase.
Sensitive data receives special handling:
// Protected buffer with mlock
type ProtectedBuffer struct {
data []byte
locked bool // mlock succeeded
}
// Constant-time zeroing
func ZeroBytes(b []byte) {
subtle.ConstantTimeCopy(1, b, make([]byte, len(b)))
runtime.KeepAlive(b)
}mlock()prevents swapping to disk (when available)- Constant-time zeroing prevents compiler optimization
- Finalizers ensure cleanup on garbage collection
Access control is enforced through an append-only blockchain:
graph LR
subgraph "Team Chain"
G[Genesis<br/>Team Created<br/>Policy Set]
G --> I1[Invite<br/>Alice Added<br/>Role: Admin]
I1 --> I2[Invite<br/>Bob Added<br/>Role: Member]
I2 --> R1[Revoke<br/>Bob Removed]
R1 --> I3[Invite<br/>Carol Added<br/>Role: Reader]
end
style G fill:#90EE90
style R1 fill:#FFB6C1
Each block contains:
- Previous block hash (SHA-256)
- Timestamp
- Operation type and payload
- Proposer signature (Ed25519)
- Required approval signatures
| Role | Manage Members | Manage Environments | Write Secrets | Read Secrets |
|---|---|---|---|---|
| Admin | Yes | Yes | Yes | Yes |
| Member | No | No | Yes | Yes |
| Reader | No | No | No | Yes |
Changes to team membership require cryptographic approval:
graph TB
Propose[Propose Change] --> Check{Team Size}
Check -->|Solo + Flag| Accept[0 Approvals]
Check -->|2-3 Members| Min1[Min 1 Approval]
Check -->|Larger| Calc[Calculate Required]
Calc --> Formula["max(minApprovals,<br/>ceil(size × threshold))"]
Formula --> Cap["Cap at size - 1<br/>(proposer excluded)"]
Approval Rules:
- Proposer cannot approve their own proposal
- Each approval is an Ed25519 signature
- Approvals verified against current member list
- Requests expire per policy (default: 7 days)
Invites bind a code to a specific public key:
sequenceDiagram
participant Admin
participant System
participant NewUser
Admin->>System: Create invite for pubkey_hash
System->>System: Generate code (XXX-XXX-XXX)
System->>Admin: Return invite code
Admin->>NewUser: Share code (out-of-band)
NewUser->>System: Join with code + public key
System->>System: Verify SHA256(pubkey) == hash
System->>System: Check not expired/revoked
System->>NewUser: Add to team chain
Invite Properties:
- 9-character alphanumeric (excludes 0/O/1/I/L)
- Bound to recipient's public key hash
- Single use, expires after configured time
- Revocable by admin before use
All peer-to-peer connections use TLS 1.3 with mutual authentication:
sequenceDiagram
participant Alice
participant Bob
Alice->>Bob: ClientHello (TLS 1.3)
Bob->>Alice: ServerHello + Certificate
Alice->>Bob: Certificate + Finished
Bob->>Alice: Finished
Note over Alice,Bob: Mutual TLS Established
Alice->>Alice: Verify Bob's fingerprint
Bob->>Bob: Verify Alice's fingerprint
TLS Configuration:
- Minimum version: TLS 1.3
- Mutual authentication required
- Self-signed certificates from Ed25519 keys
- Fingerprint verification (SHA-256 of public key)
Peers are verified by comparing fingerprints:
// Fingerprint: first 8 bytes of SHA256(public_key)
func Fingerprint(pubkey []byte) string {
hash := sha256.Sum256(pubkey)
return hex.EncodeToString(hash[:8])
}Verification Options:
- Automatic: Fingerprint in TLS certificate matches known peer
- Manual SAS: Out-of-band verification using Short Authentication Strings
For high-security scenarios, users can verify peer identity out-of-band:
SAS: 🔑 🎯 🚀 🎨 alpha bravo charlie delta
Both parties compute the same SAS from their combined public keys. Verbal confirmation detects man-in-the-middle attacks.
Environment variables are encrypted to specific recipients:
graph TB
subgraph "Encryption"
Vars[KEY=value<br/>DB_HOST=...] --> JSON[JSON Encode]
JSON --> MLKEM[ML-KEM Encapsulate<br/>to recipient pubkey]
MLKEM --> AES[AES-256-GCM Encrypt]
AES --> File[.env.enc]
end
subgraph "Decryption"
File --> Extract[Extract ciphertext]
Extract --> Decap[ML-KEM Decapsulate<br/>with private key]
Decap --> Decrypt[AES-256-GCM Decrypt]
Decrypt --> Parse[Parse JSON]
Parse --> Vars2[KEY=value]
end
sequenceDiagram
participant Requester
participant Approver
participant Chain
Requester->>Chain: Request access to ENV
Chain->>Approver: Notify of pending request
Approver->>Approver: Select variables to share
Approver->>Approver: Re-encrypt to requester's key
Approver->>Chain: Approve with signature
Approver->>Requester: Send encrypted variables
Requester->>Requester: Decrypt with private key
Each encryption uses ephemeral keys:
- ML-KEM: Fresh encapsulation per message
- YubiKey ECDH: Ephemeral P-256 key per encryption
- Compromise of long-term key doesn't expose past messages
- Operating System Security: OS provides process isolation
- Passphrase Strength: Users choose strong passphrases
- Hardware Integrity: YubiKey firmware is trustworthy
- Majority Honesty: Most team members are honest (for approvals)
| Threat | Mitigation |
|---|---|
| Passive network eavesdropping | TLS 1.3 + ML-KEM encryption |
| Future quantum computers | ML-KEM-768 + ML-DSA-65 |
| Man-in-the-middle attacks | mTLS + fingerprint verification + SAS |
| Compromised peer | Cryptographic verification of all operations |
| Stolen identity file | Argon2id encryption (128 MiB) |
| Stolen YubiKey | PIN + touch required |
| Password brute force | Argon2id memory-hard KDF |
| Compromised team member | Revocation + re-keying |
| Tampered chain history | SHA-256 hash links + signatures |
Confidentiality
- All secrets encrypted with ML-KEM-768 + AES-256-GCM
- Private keys never transmitted
- Forward secrecy via ephemeral keys
Integrity
- All chain blocks cryptographically signed
- Hash chain prevents retroactive modification
- Authenticated encryption prevents tampering
Authenticity
- Ed25519 signatures on all operations
- TLS mutual authentication
- Fingerprint-based peer verification
Non-Repudiation
- Proposer signature binds identity to action
- Approval signatures create audit trail
- Chain history is immutable
Availability
- Peer-to-peer architecture (no central server)
- Local chain copies
- Offline operation supported
The following are explicitly not protected:
- Root/Admin Access: Local privileged access can read process memory
- Endpoint Compromise: Malware on device can intercept keys
- Denial of Service: No rate limiting on chain proposals
- Metadata Privacy: Peer addresses visible to team members
- Traffic Analysis: Connection patterns observable
stateDiagram-v2
[*] --> Generated: envctl identity init
Generated --> Active: Daemon started
Active --> Rotated: Key rotation
Rotated --> Active: New key active
Active --> Revoked: Member removed
Revoked --> [*]
Active --> Backed: Mnemonic export
Backed --> Active
Active --> Linked: Device linking
Periodic key rotation limits exposure from compromise:
- Generate new Ed25519 + ML-KEM keypair
- Find all locally encrypted secrets
- Re-encrypt each with new key
- Atomically swap identity files
- Announce new public key to team
- Backup old key for 7 days (recovery)
Mnemonic Backup:
- 24-word BIP39-compatible phrase
- Encodes 32-byte Ed25519 seed
- ML-KEM key derived deterministically
- Store offline in secure location
Recovery Process:
- Enter mnemonic words
- Reconstruct Ed25519 keypair
- Derive ML-KEM keypair via HKDF
- Re-enter identity name
- Encrypt with new passphrase
All security-relevant operations are logged:
- Identity unlock/lock events
- Peer connections and disconnections
- Chain block proposals and approvals
- Secret access requests
- Key rotation events
| Asset | Protection |
|---|---|
| Identity private keys | Argon2id + AES-256-GCM (or YubiKey) |
| Environment variables | ML-KEM-768 + AES-256-GCM |
| Passphrase | Keychain or user memory |
| YubiKey-stored keys | Hardware isolation + PIN |
| Session secrets | Memory protection (mlock) |
- Public identities (signing key, ML-KEM key, name)
- Chain history (membership changes, timestamps)
- Peer addresses (for direct connections)
- Audit log entries (who accessed what)
- Your public identity (to initiate connection)
- Team membership (shared team fingerprints)
Critical cryptographic dependencies:
| Package | Purpose | Source |
|---|---|---|
crypto/ed25519 |
Signatures | Go stdlib |
crypto/ecdh |
P-256 key exchange | Go stdlib |
crypto/mlkem |
Post-quantum KEM | Go 1.23+ stdlib |
github.com/cloudflare/circl |
ML-DSA-65 | Cloudflare |
golang.org/x/crypto/argon2 |
Password KDF | Go x/crypto |
github.com/go-piv/piv-go |
YubiKey PIV | Community |
All cryptographic comparisons use constant-time functions:
import "crypto/subtle"
// Compare MACs
if subtle.ConstantTimeCompare(mac1, mac2) != 1 {
return ErrInvalidMAC
}- TLS 1.3 minimum (no downgrade)
- Strong Argon2id parameters out-of-box
- Touch policy "Always" for YubiKey
- Mutual TLS required for all connections