Skip to main content
Glama
megamen32

simple-secret-storage

by megamen32

SSS — Simple Secret Storage (MCP)

End-to-end encrypted secret delivery for LLM agents.

The agent asks for a secret by name. The server returns a one-time URL to an age-encrypted blob (X25519). Only the agent's private key can decrypt it. The server never sees the plaintext in the wire response.

Request a form

Call ask_secret({}): one call returns a browser input link and an automatic random name. An optional name reuses a named handoff; an optional UUID request_id keeps unnamed retries on the same form. Never ask the human to invent a name. The form tool returns pending with input_url, or ready, and never retrieves a secret or a download URL. After the user submits, use only the separately authorized stdin consumer. get_secret also accepts an omitted name for older clients.

Always show a pending form link in a normal assistant message. Many harnesses hide MCP tool results from the user. Do not assume that returning input_url from the MCP call displayed it. Reply with the exact returned URL as a clickable Markdown link, for example: Введите секрет в [защищённой форме](URL), replacing URL with the exact returned input_url. Never invent or alter the URL.

HTTP MCP uses the official SDK's stateless JSON transport. Pending forms and encrypted secrets stay in storage across restarts; stale MCP session headers do not block the next call. The stdio entrypoint uses the same tool definitions.

Related MCP server: agent-secret

Why

When an LLM agent needs a password / API key / token, the worst thing is to have the secret appear in the MCP tool response — it ends up in the model context, the chat history, and any logs that record tool outputs.

SSS solves this by:

  1. Server stores the secret encrypted (AES-256-GCM, master key in ~/.sss/key, mode 0600).

  2. Agent registers an age (X25519) public key via MCP register_agent.

  3. get_secret(name, agent_id) returns a one-time fetch URL. The fetch responds with armored age ciphertext for that agent's public key.

  4. Agent decrypts locally with its private key and pipes the plaintext into the target command. Plaintext exists only in the command's stdin.

The server never has the agent's private key, so even if the server is compromised, the attacker only gets ciphertext they can't decrypt.

If the agent doesn't register (no agent_id parameter), get_secret falls back to base64 for convenience — but the server does see the plaintext at fetch time. Use agent_id for any non-trivial secret.

Quick start

1. Server: build, install, systemd

cd ~/agents-projects/simple-secret-storage
npm ci                          # installs production deps
npm run build                   # dev deps included for tsc
sudo install -m 0644 systemd/sss.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now sss

Generate a strong bearer token once:

echo "SSS_API_KEY=$(openssl rand -hex 32)" \
  > ~/.config/sss-mcp/api-key.env
chmod 600 ~/.config/sss-mcp/api-key.env

The systemd unit reads this file via EnvironmentFile=.

2. nginx: terminate TLS, proxy to SSS

nginx/pswd.bezrabotnyi.com.conf ships in the repo. Adapt paths and hostnames; symlink into sites-enabled, then:

sudo certbot certonly --nginx -d pswd.bezrabotnyi.com \
  --non-interactive --agree-tos --register-unsafely-without-email
sudo nginx -t && sudo systemctl reload nginx

The config has access_log off for /i/, /submit/, and /d/ locations — the URLs themselves are the credentials.

3. CLI wrappers for agents

Two scripts ship in bin/. Pick based on the threat model:

Script

Plaintext appears in…

When to use

sss-get

our stdout (then into pipe)

interactive use, sss-get <name> | xxd, scripts you own

sss-run

only the consumer's stdin

handing a secret to an external command you don't trust with your stdout/argv/env

Install both:

ln -sf "$(pwd)/bin/sss-get.mjs" ~/.local/bin/sss-get
ln -sf "$(pwd)/bin/sss-run.mjs" ~/.local/bin/sss-run

sss-get <name> — fetch + print to our stdout

sss-get yandex_password              # → plaintext on stdout
sss-get yandex_password | <your-command>

First run creates ~/.config/sss-mcp/agent-identity.json (mode 0600) with a fresh X25519 identity and registers the public key with the server. Subsequent runs reuse it.

sss-run <name> -- <consumer> [args...] — pipe straight to a child

sss-run yandex_password -- curl -u : https://passport.yandex.ru/
sss-run api_token -- ssh -i ~/.ssh/id_ed25519 user@host 'echo ok'

The decrypted value is fed only to the consumer's stdin. It never appears in our process's stdout, argv, or environment, and there is no intermediate file on disk. The CLI itself enforces the -- separator so there's no chance of accidentally treating the consumer as an option.

  • generate the agent identity if missing,

  • call MCP register_agent,

  • call MCP get_secret(name, agent_id),

  • fetch /d/<token> and decrypt the age ciphertext locally,

  • write plaintext to stdout.

4. Codex CLI

Already done in ~/.codex/config.toml:

[mcp_servers.sss]
url = "https://pswd.bezrabotnyi.com/mcp"
bearer_token_env_var = "SSS_API_KEY"

SSS_API_KEY is loaded by ~/.profile from ~/.config/sss-mcp/api-key.env.

Restart Codex (codex in a new login shell). The remote SSS endpoint exposes ask_secret, list_secret_run_profiles, ask_secret_run, get_secret, list_secrets, register_agent, delete_secret, and rename_secret. The narrow stdio entrypoint exposes ask_secret, list_secret_run_profiles, and ask_secret_run.

For tool calls, pass agent_id to get_secret to get true E2E.

5. Claude Code / Cursor / other MCP clients

claude mcp add sss --transport http \
  --url https://pswd.bezrabotnyi.com/mcp \
  --header "Authorization: Bearer ***"

How agents get the API key

Provision an SSS API key only to a machine whose operator is authorized for full vault access, using a private, approved channel. The machine running sss-get or sss-run needs the key; SSS does not register or classify hosts. Anyone holding an SSS Bearer key can list, read, save and delete every secret, so treat it as full vault access. Never put a live key in this README, chat, shell history, or a command-line argument.

Create the client-side file, then populate it from a trusted secret-delivery channel without putting the key in shell history or a command argument:

mkdir -p ~/.config/sss-mcp
umask 077
install -m 600 /dev/null ~/.config/sss-mcp/api-key.env
# Use a trusted editor or secret-provisioning tool to set SSS_API_KEY in this file.
chmod 600 ~/.config/sss-mcp/api-key.env

sss-get and sss-run read this file automatically. The systemd unit on the server reads the same path on that host.

MCP tools

The MCP server identifies itself as simple-secret-storage. Existing client configuration aliases such as AskSecret remain valid; all harnesses use the same SSS backend.

Tool

Parameters

What it returns

ask_secret

name?, request_id?

pending with a one-time browser input URL, or ready; never a secret value or download URL.

list_secret_run_profiles

—

Configured consumer profile names only; never executable paths or arguments.

ask_secret_run

name, profile

Runs an exact configured profile with the secret on stdin; no value in the result, arguments or environment.

register_agent

agent_id, public_key (age1...), label?

Confirmation. Persists the public key in ~/.sss/agents.json.

get_secret

name, agent_id?

If pending → returns /i/<token> URL for the user to submit the value. If ready → returns /d/<token> URL (age if agent_id, else base64) with pipe-usage hint.

list_secrets

—

Names + metadata (no values).

delete_secret

name

Confirmation.

rename_secret

name, new_name

Renames the stored entry without reading or returning its value.

Call list_secret_run_profiles before ask_secret_run. A profile is a preconfigured server-side consumer; do not guess a name or pass arbitrary commands through MCP. If no suitable profile exists, continue in the shell where the current task runs: check hostname -f and command -v sss-run, then run sss-run <name> -- <fixed-consumer>. The user does not need to classify the machine. If the consumer must run on another server, use ssh itself as the stdin consumer: sss-run <name> -- ssh server-alias <fixed-remote-command>. The secret reaches the consumer only through stdin. Never use shell expansion, command substitution, environment variables, or arguments to move the value. The /d/ retrieval grant is single-use and expires after five minutes; no temporary profile is needed for a one-off operation.

Manage long-lived secrets

Open https://pswd.bezrabotnyi.com/ to manage named secrets. The page and its browser management API use the existing auth.bezrabotnyi.com cookie SSO. The page lists metadata and supports adding, renaming and deleting entries. Use Show on a ready entry to explicitly reveal its value; Hide clears it from the page, and refreshing the list clears all revealed values. Pending entries cannot be revealed. The value endpoint is authenticated and returns no-store responses. The /mcp endpoint keeps its Bearer-token authentication for agent clients.

This SSO gate is limited to the management page and /api/ browser requests. The anonymous /secret upload and one-time /i/<token> input links remain available under their existing contracts. A link token grants one pending submission; the management list does not expose it.

Web UI for the user

The user opens the link from get_secret's not_found / pending_user_input response. URL pattern:

https://pswd.bezrabotnyi.com/i/<token>

A simple password input form. POST goes to /submit/<token>, value is encrypted and stored. The link works once and expires.

For a passwordless, anonymous upload that is not tied to a pending request, open https://pswd.bezrabotnyi.com/secret. The page accepts only the secret value, stores it encrypted under a generated UUID, and returns that opaque ID.

Architecture

User ─browser─→ nginx (pswd.bezrabotnyi.com, TLS, access_log off for /i/, /d/, /submit/)
                       └─127.0.0.1:8743─→ SSS (systemd, single Node process)
                                              ├─ /api/secrets   (SSO browser or Bearer, list/save/rename/delete)
                                              ├─ /secret       (anonymous create, returns ID)
                                              ├─ /i/<token>     (anonymous form)
                                              ├─ /submit/<token>(anonymous POST)
                                              ├─ /d/<token>     (one-time, age-cipher OR base64)
                                              └─ /mcp           (Streamable HTTP, Bearer)
                                                            ↕ JSON-RPC
LLM Agent ─MCP──→ same SSS ─MCP─→ ask_secret, ask_secret_run, get_secret, list_secrets, register_agent, rename_secret, delete_secret
LLM Agent ─CLI──→ sss-get / sss-run ──→ same flows; decrypts locally with age identity

Files:

~/agents-projects/simple-secret-storage/
├── src/
│   ├── server.ts        # Express app + MCP tools + /i/, /submit/, /d/, /api/
│   ├── storage.ts       # ~/.sss/{key, secrets.json, blobs/, agents.json}
│   └── crypto.ts        # AES-256-GCM (storage) + age-encryption (wire)
├── bin/sss-install.mjs   # npx entrypoint → bash install.sh
├── bin/sss-get.mjs       # CLI: fetch and print to stdout
├── bin/sss-run.mjs       # CLI: pipe directly to a child process's stdin
├── systemd/sss.service  # Single-process systemd unit
├── nginx/pswd.bezrabotnyi.com.conf
└── dist/                # tsc output

Threat model

Protected

  • Plaintext in MCP response / logs — server returns URL + ciphertext only.

  • Plaintext on disk — AES-256-GCM, master key at ~/.sss/key (0600).

  • Plaintext in nginx access logs — /i/, /submit/, /d/ have access_log off.

  • Server compromise with agent_id mode — attacker gets ciphertexts only, no agent private keys.

  • Network MITM — TLS via Let's Encrypt; the nginx config uses the same ssl_certificate_* files as other *.bezrabotnyi.com sites.

NOT protected

  • Plaintext at the command-STDIN destination. If the receiving command writes its stdin to a file or logs it, the secret ends up there. sss-run <name> -- <cmd> keeps the secret in the kernel pipe buffer only — there is no intermediate file, no stdout copy in the parent process, and the consumer's argv/env never see it. There is no way around this in principle — the secret has to reach the command somehow.

  • Plaintext in argv — don't cat /d/... | age --decrypt | xargs cmd $secret. Use stdin redirection or --password-file.

  • Master key theft — ~/.sss/key is 0600 but unencrypted. If an attacker reads it, they can decrypt all stored blobs.

  • Compromise of the user's browser at the /i/<token> URL — the one-time token has 256 bits of entropy; capture-and-replay within the 5-minute window can submit an attacker-chosen value.

  • Loss of agent identity — ~/.config/sss-mcp/agent-identity.json is the only thing that lets the agent decrypt. Back it up encrypted or treat it as a one-shot device credential.

Files created at runtime

~/.sss/key             32-byte AES key (mode 0600)
~/.sss/secrets.json    { name: { sha256, created, pending?, ... } } (mode 0600)
~/.sss/blobs/<name>.enc  AES-encrypted secret blob (mode 0600)
~/.sss/agents.json     { agent_id: { publicKey, ... } } (mode 0600)
~/.config/sss-mcp/api-key.env            SSS_API_KEY=... (mode 0600)
~/.config/sss-mcp/agent-identity.json    age identity (mode 0600)

Operational notes

  • npm run build requires typescript and @types/* (dev deps). On the production server, run npm ci (full install) before npm run build, not npm ci --omit=dev.

  • The systemd unit must use /usr/local/bin/node (v22+). /usr/bin/node on this host is v12 and will fail to parse ?? and other ES2020+ syntax.

  • The agent identity is per-machine. If you move Codex to a new host, delete ~/.config/sss-mcp/agent-identity.json and let it regenerate; the new public key will register automatically on the next sss-get or sss-run call.

  • The HTTP fetch URL (/d/<token>) is single-use (token deleted on first GET) and expires after 5 minutes. There is no refresh — if you missed it, call get_secret again to get a new URL.

Manual lifecycle

sudo systemctl status sss            # running?
sudo systemctl restart sss           # after code changes
sudo journalctl -u sss -f            # live logs
sudo systemctl disable --now sss     # shut down

Build for a fresh host

git clone <repo> ~/agents-projects/simple-secret-storage
cd ~/agents-projects/simple-secret-storage
npm ci
npm run build
sudo install -m 0644 systemd/sss.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now sss
ln -sf "$(pwd)/bin/sss-get.mjs" ~/.local/bin/sss-get
ln -sf "$(pwd)/bin/sss-run.mjs" ~/.local/bin/sss-run

Generate a fresh API key on the new host:

echo "SSS_API_KEY=$(openssl rand -hex 32)" > ~/.config/sss-mcp/api-key.env
chmod 600 ~/.config/sss-mcp/api-key.env
sudo systemctl restart sss

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    MCP server for creating and managing SOPS-encrypted secret files using age encryption, enabling AI agents to generate and manage secrets without ever seeing plaintext values.
    11
    36 PyPI
    Apache 2.0
  • A
    license
    Not graded
    quality
    A
    maintenance
    MCP server for one-time secret sharing, enabling AI agents to securely claim and store secrets (e.g., API keys) via claim codes without exposing them in chat transcripts.
    1 npm
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    MCP server enabling AI agents to use secrets (API keys, tokens) via encrypted vault, executing HTTP/shell/SSH actions server-side while never exposing secret values to the AI.
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    MCP server for agent-native secrets management that stores encrypted secrets with AES-256-GCM, issues short-lived scoped tokens, and injects secrets into requests server-side so agents never handle plaintext credentials.
    20 npm
    MIT