Skip to content

Security hardening: OS credential store, human consent for writes, encrypted cache - #28

Closed
AdelsJarlen wants to merge 11 commits into
pragprogrammer:mainfrom
AdelsJarlen:feat/secure-credentials-and-consent
Closed

AdelsJarlen wants to merge 11 commits into
pragprogrammer:mainfrom
AdelsJarlen:feat/secure-credentials-and-consent

Conversation

@AdelsJarlen

Copy link
Copy Markdown

Security hardening, not remediation. Upstream was audited and found clean — no
malware, no exfiltration, a single pinned endpoint. This closes a mundane but
real exposure and adds a consent gate on writes.

What changed

The token moves out of plaintext config. The model never had access to it —
this server is a local subprocess and the LLM only sees tool return values. The
actual leak was claude mcp add-json ... "env":{"YNAB_API_KEY":"..."}, which
writes the token in plaintext into ~/.claude.json or
claude_desktop_config.json. Any agent with a file-read tool, including Claude
Code itself, can open that file. It now lives in the OS credential store via
keyring, with mcp-ynab auth login to put it there.

Every write requires human confirmation, with a real diff. All 16 mutating
tools now elicit approval before sending anything to YNAB. The security property
is that the "before" shown to the human comes from the YNAB API, not from
anything the model said
. Two things hold that up:

  • A tool parameter annotated Context is injected by the SDK and does not
    appear in the tool's input schema, so the model cannot supply, forge or
    suppress it. Consent cannot be satisfied by anything the model emits.
  • Pre-images are read through _shared.client, bypassing the cache — a stale
    row would show a false "before". Each call site fingerprints the resolved
    payload before eliciting and asserts it afterwards, so nothing is re-resolved
    between the prompt and the write.

Bulk operations itemise every row with its own diff rather than summarising a
count, because "12 transactions" is exactly the prompt a person waves through
unread. import_transactions has no knowable diff, so it names the linked
accounts and says plainly that the result cannot be previewed.

The cache is encrypted at rest. It holds payees, memos, amounts and category
names. Payload columns are AES-256-GCM with the key in the credential store,
implemented as a SQLAlchemy TypeDecorator so src/cache/service.py is
untouched.

Opt-in read-only mode. YNAB_READ_ONLY=1 drops the 16 write tools at
registration time, so they never reach the model's tool list. Off by default.

Honest limits (all stated in SECURITY.md)

  • DPAPI/Keychain/Secret Service scope secrets to the logged-in user, not to
    a program. Code running as the same user can still retrieve the token. Closing
    that needs a separate OS account, a hardware token, or a broker process —
    none of which a pip-installable server can deliver.
  • The cache AAD is mcp-ynab:<table>.<column>, which binds ciphertext to its
    table and column but not to its row. Someone with DB write access could
    swap ciphertexts between rows of one table. Acceptable (they already have
    write access) and documented rather than hidden.
  • Lookup and delta-sync columns stay plaintext, leaking entity counts and sync
    timing but no amounts, payees, memos or category names.
  • 0600 on POSIX only; on Windows the file inherits the user-profile ACL.

Reviewer notes

The consent gate is only as strong as the client's elicitation support. On a
client that does not implement elicitation/create, writes now refuse outright
rather than degrading. Verified in practice: Claude Code's terminal CLI supports
it (2.1.76+); Claude Desktop does not (anthropics/claude-code#41110), and the
desktop Code tab advertises the capability then auto-declines without rendering
a prompt (anthropics/claude-code#94450). YNAB_READ_ONLY=1 is the clean
configuration for those.

Two deviations from the original brief, both deliberate:

  • allow_env_key defaults to true, not false. The brief specified = False
    while also requiring the env var be permitted with a deprecation warning;
    those contradict. Defaulting it off would break scripts/smoke_test.py and
    every existing install. YNAB_ALLOW_ENV_KEY=0 gives the strict behaviour.
  • crypto.encrypt() produces exactly nonce(12) || ciphertext || tag as
    specified, but the storage layer prefixes one framing byte so
    YNAB_CACHE_ENCRYPTION=0 can be toggled without stored rows decoding as
    garbage.

auth login --stdin also widens the brief, which said the token must only come
from getpass. It keeps the token out of argv and shell history — the
property that actually mattered — and exists because pasting into a hidden
prompt is broken on the Windows console (see below).

A migration bug worth knowing about. The first version of the cache
migration dropped cached_entity but left server_knowledge. Delta sync would
then have believed it was current, fetched only changes since that knowledge,
and the dropped entities would never have returned. The two now drop together.

Tool surface is unchanged: 47 tools, identical names and parameter schemas,
no ctx leaking into any input schema. exclude_fields behaviour and
FIELDS.md semantics are preserved.

Verification

  • uv run pytest — 153 passed (105 pre-existing, 48 new in
    tests/test_security.py). Existing tests keep their assertions on exactly
    which payload reaches the cache service; they only gained the fixtures the
    gate requires.
  • python scripts/smoke_test.py — passes on highest (resolves mcp 2.2.0) and
    --resolution lowest-direct (mcp 2.0.0, keyring 25.0, cryptography 43.0).
  • Driven end to end over real stdio as a client advertising elicitation: the
    prompt renders, decline blocks the write, a client without elicitation is
    refused, and YNAB_READ_ONLY=1 serves 31 tools instead of 47.
  • Encrypted round trip verified against a real SQLite file: payload stored as an
    opaque BLOB, lookup columns intact, plaintext absent from the bytes on disk.

The last two commits fix bugs found while setting this up on Windows for the
first time: a Ctrl+V control character reaching the Authorization header and
producing an opaque HTTP 400, a sys.stdin.isatty() check that returns true
under Git Bash even when redirected (so auth login hung forever instead of
erroring), and PowerShell's UTF-8 BOM breaking --stdin.

🤖 Generated with Claude Code

AdelsJarlen and others added 11 commits September 15, 2026 14:22
keyring backs the OS credential store that will hold the YNAB token and the
cache encryption key; cryptography provides AES-256-GCM for encrypting cached
budget data at rest.

Both carry upper bounds, matching the convention the dependency block already
documents. cryptography was already present transitively via mcp[cli]; it is
declared explicitly now that it is imported directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
The token never reached the model — this server is a local subprocess and the
LLM only sees tool return values. The leak was the config file: `claude mcp
add-json ... "env":{"YNAB_API_KEY":"..."}` writes the token in plaintext into
~/.claude.json or claude_desktop_config.json, which any agent with a file-read
tool can open. This moves it into the OS credential store instead.

src/security/credentials.py wraps keyring (Windows Credential Manager, macOS
Keychain, Secret Service) under service name "mcp-ynab". Settings now resolves
the token in order: credential store, then YNAB_API_KEY with a deprecation
warning on stderr, else a MissingCredentialsError pointing at `auth login`.
The store wins over the environment so a fresh login takes effect even when a
stale key is still sitting in an MCP client config. YNAB_ALLOW_ENV_KEY=0
refuses the environment variable outright.

Settings.ynab_api_key is a SecretStr; the single .get_secret_value() call is
in YNABClient.__init__ where the Authorization header is built, and the token
is not stored on the client afterwards.

These stores scope secrets to the logged-in user, not to a program: code
running as the same user can still ask for the same secret. SECURITY.md (added
later in this branch) says so plainly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
YNAB error bodies and httpx exception reprs can echo request headers, and a
tool's error return value is a string the model reads. handle_errors now
renders every error payload through scrub(), which replaces any registered
live secret with ***REDACTED***. Scrubbing the rendered JSON rather than each
field covers nested values and any field added here later.

The token registers itself in YNABClient.__init__, the one place it is
unwrapped. This is a backstop behind SecretStr, not a substitute for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
The `mcp-ynab` entry point now resolves to src.cli:main. A bare invocation
still starts the stdio server, so existing MCP client configs keep working.

- auth login: reads the token with getpass (never argv, never echoed, never in
  shell history), validates it against GET /user, and only then stores it. An
  invalid token is refused rather than saved, so a typo fails here instead of
  surfacing as a confusing auth error later.
- auth status: token source and fingerprint, never the token. Warns when a
  stale YNAB_API_KEY is being shadowed by the credential store.
- auth logout: deletes the stored token.
- cache purge: deletes the cache DB, its WAL sidecars, and the cache key.

Server modules are imported lazily inside serve(), since importing them
constructs Settings, which refuses to build without a token — `auth login` has
to run before a token exists. Subcommand output goes to stdout because it is
read by a human in a terminal; the server path still prints nothing there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
The cache holds payees, memos, amounts and category names — a fairly complete
picture of the user's finances — in a SQLite file under the user profile. The
payload columns are now AES-256-GCM, with a 256-bit key generated on first use
and kept in the OS credential store beside the token.

EncryptedJSON is a SQLAlchemy TypeDecorator, so src/cache/service.py is
untouched: it still hands dicts to the ORM and gets them back. The envelope is
nonce(12) || ciphertext || tag, with AAD "mcp-ynab:<table>.<column>". That AAD
binds a ciphertext to its table and column but not to its row, so someone with
write access to the DB could swap ciphertexts between rows of one table; they
already have write access, and SECURITY.md documents the limit rather than
hiding it.

plan_id, entity_type, entity_id, cache_key, is_deleted, cached_at, ttl_seconds,
knowledge and last_synced_at stay plaintext because lookups and delta sync need
them. That leaks metadata — entity counts, sync timing — but no amounts,
payees, memos or category names.

A storage-layer framing byte in front of the blob distinguishes an encrypted
payload from a plaintext one, so YNAB_CACHE_ENCRYPTION=0 (debugging only) can
be toggled without the stored rows decoding as garbage.

Migration extends the existing drop-and-rebuild in _migrate_cache_tables: a
non-BLOB `data` column on cached_entity or response_cache drops that table.
Dropping cached_entity also drops server_knowledge — otherwise delta sync would
still believe it was current, fetch only changes since that knowledge, and the
entities dropped here would never come back.

A missing credential store fails closed with a message naming
YNAB_CACHE_ENCRYPTION=0, rather than quietly writing plaintext while the user
believes the cache is encrypted. The DB file is chmod 0600 on POSIX; on Windows
it inherits the user-profile ACL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
All 16 mutating tools now ask a person before they change anything, and show
them a real before/after diff rather than a summary.

The security property: the "before" comes from the YNAB API, not from anything
the model said. Two things make that hold.

A tool parameter annotated `Context` is injected by the SDK and does not appear
in the tool's input schema (verified against the installed mcp 2.0.0), so the
model cannot supply, forge or suppress it. Consent therefore cannot be
satisfied by anything the model emits.

Pre-images are read through `_shared.client`, which bypasses the cache. That is
not optional: a stale cached row would show the human a false "before". Each
call site then fingerprints the resolved payload before eliciting and asserts
the fingerprint after, so nothing can be re-resolved between the prompt and the
write — a guarantee a test can now assert rather than a convention.

Declining, cancelling, an elicit that raises, or a client that never advertised
elicitation all block the write. There is no fallback that applies the change
unconfirmed; ConsentUnavailable is surfaced to the model as an actionable error
naming the fixes.

Bulk operations itemise every row with its own diff and never summarise as a
count — "12 transactions" is exactly the prompt a human waves through without
having seen it. If that makes the prompt long, the prompt is long.
import_transactions has no meaningful diff, so it names the linked accounts
that may be touched and states plainly that the result is not knowable in
advance.

Tool names and visible parameter schemas are unchanged: 47 tools, none leaking
ctx. The existing tests keep their assertions on exactly which payload reaches
the cache service; they only gained the fixtures the gate now requires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
YNAB_READ_ONLY=1 stops the 16 mutating tools being registered at all, so they
never reach the model's tool list: there is nothing to call and nothing to talk
the server out of, rather than an offered capability that then refuses.

Off by default — writes stay enabled, per the brief. With it on the server
offers 31 read tools instead of 47.

consent.confirm() also refuses in read-only mode. That guard should be
unreachable given the registration gate; it costs nothing and covers a tool
that somehow is reachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
SECURITY.md states the threat model in full, including the parts that are not
flattering: the credential store scopes secrets to the logged-in user, not to a
program, so an agent with shell access as that user can still retrieve the
token. Closing that needs a separate OS account, a hardware token, or a broker
process, none of which a pip-installable server can deliver. Also documents
what the cache encrypts, the metadata the plaintext lookup columns still leak,
the AAD row-swap limitation, and how to rotate a token and purge the cache.

README replaces the config examples that embedded YNAB_API_KEY in JSON with the
`mcp-ynab auth login` flow. This is the change that matters most in practice:
the README is where people copy the insecure pattern from. Adds the environment
variable table, a worked example of a consent prompt, and a plain warning that
writes refuse outright on a client without elicitation support.

CLAUDE.md notes the new src/security/ package and src/cli.py, and records the
conventions for a mutating tool: a Context parameter, a pre-image from
_shared.client rather than the cache, and registration through mutating_tool().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
Two wording fixes found by reading the rendered prompts rather than the code:

- The closing line said "Confirm to apply exactly this. Nothing else will be
  sent", which reads oddly on a delete. Now "Confirm to proceed with exactly
  this, and nothing else" — same guarantee, correct for every operation.
- An em dash in the update_category_for_month subject and in the cache
  encryption error. Every other prompt string is ASCII; these two were the
  exceptions, and prompt text crosses transports and consoles whose encoding we
  do not control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
Three bugs, all hit while setting the server up for the first time on Windows.

A token containing a control character built a malformed Authorization header,
and YNAB answered with a bodyless HTTP 400. This happens in practice: on the
Windows console Ctrl+V at a hidden getpass prompt is delivered as the literal
0x16 character instead of pasting, so the "token" was one control character.
_why_not_a_token() now catches control characters, whitespace and obviously
short input before the network call, and names Ctrl+V as the likely cause.
Verified against the live API: a well-formed but wrong token gives 401, a
control character gives 400.

_validate_token reported any non-YNABError as "could not reach YNAB", which was
wrong for that 400 — YNAB was reached and answered. HTTPStatusError and
RequestError are now distinguished.

The interactive guard checked only sys.stdin.isatty(). Under Git Bash/MSYS on
Windows that returns True even when stdin is redirected, and getpass resolves to
win_getpass, which reads the console directly and ignores the redirect — so the
guard never fired and the process blocked forever waiting for a human who was
not there. It now requires stdout to be a terminal too, turning an indefinite
hang into an actionable message.

Adds `auth login --stdin` for terminals that will not paste into a hidden
prompt, and for headless setups. Piping from a file keeps the token out of argv
and out of shell history, which is the property that matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
Windows PowerShell 5.1 prefixes anything it pipes to a native executable with a
UTF-8 BOM. A BOM is not whitespace, so .strip() left it in place, and the
control-character check added in the previous commit then rejected it — which
broke the most natural way to feed a token in on the platform the --stdin flag
was added for.

Stdin is now decoded as utf-8-sig, which consumes the BOM, and any stray BOM
elsewhere in the input is dropped too; clipboard and editor round-trips
occasionally introduce one.

Regression tests cover all four input shapes seen for real: the Ctrl+V control
character, whitespace, a truncated paste, and BOM-prefixed stdin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
@AdelsJarlen

Copy link
Copy Markdown
Author

Opened against the wrong repository by mistake — this belongs on the fork, not upstream. Closing immediately; apologies for the noise.

@AdelsJarlen
AdelsJarlen deleted the feat/secure-credentials-and-consent branch September 25, 2026 20:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant