Security Audit: avault.py — findings and recommendations #16
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Security Audit: avault.py
Auditor: Doxios (AI agent, Cobot maintainer)
Requested by: Ben (@webdiverblue)
Date: 2026-03-06
Scope:
scripts/avault.py(1494 lines, commit on main)Methodology: Static code review — no runtime testing performed
Severity Legend
🔴 CRITICAL Findings
C1: Silent nsec Fallback Undermines Entire Security Model
Location:
get_nsec_string()(line ~174),cli_or_daemon()(line ~738)Issue: When the daemon isn't running, every CLI command silently falls back to reading the nsec from
~/.profileorNOSTR_NSECenv var. This means the entire NIP-46 remote signing flow — the core value proposition — is bypassed whenever the daemon is down.Impact: An attacker who can read
~/.profilegets full vault access. The daemon's RAM-only security guarantee is meaningless while the fallback exists.Recommendation:
--no-fallbackflag (or make it the default) that refuses to operate without the daemon⚠️ INSECURE: Using plaintext nsec from ~/.profileC2: Secrets Passed in CLI Arguments (Visible in Process List)
Location:
cmd_set(), argparse--valueparameter (line ~942)Issue:
avault set myservice --key API_KEY --value sk-1234secretputs the secret value in the process argument list, visible to any user viaps auxor/proc/PID/cmdline.Impact: Any local user can harvest secrets by watching process lists. On shared systems, this is trivially exploitable.
Recommendation:
--valuefrom stdin when value is-or when--valueis omitted:echo 'sk-1234' | avault set myservice --key API_KEYavault set myservice --key API_KEY→Value:(with terminal echo disabled viagetpass)🟠 HIGH Findings
H1: No Authentication on Unix Socket
Location:
serve()(line ~628),handle_client()(line ~611)Issue: The daemon socket is protected only by filesystem permissions (
chmod 0o600). Any process running as the same user can connect and issue commands, includingget(read any secret),set(modify secrets), andshutdown.Impact: If any other process running as the same user is compromised (e.g., a Cobot plugin, a Node.js app, a cron script), it has full vault access. This is especially relevant because avault's purpose is to isolate secrets from the agent — but the agent process (same user) can just talk to the socket directly.
Recommendation:
SO_PEERCREDto verify the connecting PID against an allowlistH2: No Memory Wiping on Shutdown
Location:
serve()cleanup (line ~687)Issue: Setting Python references to
Nonedoes NOT erase the secret data from memory. The original bytes remain in the Python heap until garbage collected and the memory page is reused. A memory dump (core dump,/proc/PID/mem, cold boot attack) can recover secrets after "wiping".Impact: The README promises "Kill the daemon or reboot → secrets gone." This is not strictly true. Secrets persist in memory until GC and page reuse.
Recommendation:
ctypes.memset()ormmapwith explicit zeroing for sensitive buffersresource.setrlimit(resource.RLIMIT_CORE, (0, 0))mlock()to prevent secrets from being swapped to diskH3: Vault File Written Without Atomic Rename
Location:
save_vault()(line ~234)Issue: If the process crashes mid-write,
secrets.vaultis corrupted — partially written ciphertext that can't be decrypted. All secrets are lost.Impact: Data loss on crash during save. The ACID guarantee mentioned in the PRD is not actually implemented.
Recommendation:
H4:
cmd_initWrites Generated nsec to~/.profilein PlaintextLocation:
cmd_init()(line ~812)Issue: When generating a new keypair, the nsec is appended to
~/.profilein plaintext. This directly contradicts the security model.Impact: The newly generated nsec is immediately compromised to any process that can read
~/.profile.Recommendation:
nsec.enc(encrypted to owner). Never write plaintext nsec to disk.H5: Background Daemon Doesn't Redirect File Descriptors Safely
Location:
daemon_start()background fork (line ~724)Issue: File descriptors 0/1/2 are not properly redirected.
sys.stdin.close()closes the Python wrapper but not necessarily fd 0. A new file opened later could get fd 0, causing confusion. Also missing double-fork for proper daemonization.Recommendation:
os.dup2(devnull_fd, 0/1/2),os.umask(0o077)🟡 MEDIUM Findings
M1: 10MB Message Size Limit Is Excessive
Location:
recv_msg()(line ~152)Issue: A rogue client can force the daemon to allocate 10MB per request. With socket backlog of 5 and threaded handling, this allows ~50MB memory consumption from a malicious local process.
Recommendation: Reduce to 64KB or 256KB. No legitimate vault request should exceed a few KB.
M2: No Rate Limiting or Connection Limits on Socket
Location:
serve()(line ~628)Issue: Unlimited concurrent connections, no rate limiting. A local DoS can exhaust daemon threads/memory.
Recommendation: Limit concurrent connections (e.g., max 10 threads). Add simple rate limiting per second.
M3:
_auto_commit()Silently Swallows All ErrorsLocation:
_auto_commit()(line ~251)Issue: If git push fails, the operator has no idea their vault changes aren't backed up. A network outage could mean days of unsynced changes.
Recommendation: Log failures (at minimum to stderr). Consider a status flag or file that tracks last successful push.
M4:
git pushRuns as DetachedPopen— No Error HandlingLocation:
_auto_commit()(line ~266)Issue: Fire-and-forget push with discarded output. If push fails (auth expired, remote down, merge conflict), it's silently lost.
M5: No Integrity Verification on Vault Load
Location:
load_vault()(line ~203)Issue: NIP-44 provides authenticated encryption (Poly1305 MAC), which is good. But there's no additional integrity check — if the ciphertext file is truncated (partial write from H3), the decryption will fail with a cryptic error rather than a clear "vault corrupted" message.
Recommendation: Wrap
load_vault()in a try/except that gives a clear error message and suggests recovery steps (fleet-recover, restore from git).M6:
export --shellDoesn't Sanitize Key NamesLocation:
cmd_export()(line ~984)Issue: Secret key names are not validated. A malicious key name like
FOO$(rm -rf /)would be injected into the shell export. Values are escaped (single-quote wrapping), but keys are not.Recommendation: Validate key names against
^[A-Z_][A-Z0-9_]*$before outputting.🟢 LOW Findings
L1: PID File Race Condition
Location:
daemon_running()(line ~395) only checks socket existence, not PID liveness.daemon_start()checks socket but not PID file.Recommendation: Verify PID is actually alive (
os.kill(pid, 0)) before assuming daemon is running.L2: Socket Timeout Allows Slow Client DoS
Location:
handle_client()—conn.settimeout(10)means a slow client can hold a thread for 10 seconds.L3:
parse_profile_exports()Regex May Miss Edge CasesLocation: Line ~358. The regex
'^export\s+([A-Z_][A-Z0-9_]*)="?([^"\n]*)"?$'doesn't handle single-quoted values, escaped quotes, or multi-line values.L4: No File Permission Check on
secrets.vaultRecommendation: Warn if
secrets.vaultis world-readable (should be 600).ℹ️ Design Observations
D1: Self-Encryption Pattern
The vault is encrypted with
nip44_encrypt(agent_sk, agent_pk, ...)— the agent encrypts to itself. This is correct for NIP-44 (conversation key derived from both keys), but worth documenting that this means anyone with the agent's nsec can decrypt. The nsec IS the vault key.D2: Central Manifest Is a Smart Design
secrets.central— metadata encrypted to the owner, no values — is a well-thought-out pattern. The owner can audit what secrets exist across their fleet without seeing values. 👏D3: NIP-46 Flow Is Well-Structured
The
start_nip46()method handles both known-identity (already paired) and ephemeral-identity (fresh pairing) flows cleanly. The QR code for initial pairing is a nice UX touch.D4: Fleet Commands Are Forward-Looking
fleet-auditandfleet-recoverwith owner nsec show good operational thinking. Recovery path exists if an agent is lost.Summary
Overall Assessment
The cryptographic foundation is solid — NIP-44 (XChaCha20-Poly1305) is a strong choice, NIP-46 remote signing is well-implemented, and the architecture (daemon holds secrets in RAM, CLI talks over socket) is the right pattern.
The critical issues are operational, not cryptographic:
Recommended fix order: C1 → C2 → H3 → H4 → H1 → H2 → everything else.
The code is well-structured, readable, and shows good security thinking in the crypto layer. The gaps are in the OS-level hardening around it. Fixable without architectural changes.
CC: @webdiverblue @k9ert
Filed by Doxios 🦊 on behalf of Ben