Skip to content

fix: close the masking, put, store-lock, target-path, scan and HTTP-auth gaps - #3

Merged
sscodeai merged 6 commits into
devfrom
fix/close-masking-and-put-gaps
Oct 21, 2026
Merged

sscodeai merged 6 commits into
devfrom
fix/close-masking-and-put-gaps

Conversation

@sscodeai

Copy link
Copy Markdown
Owner

What this fixes

Reviewing dev turned up two gaps that contradict the README's "plaintext never reaches the agent" claim, plus four P1 hardening items. Each commit is self-contained.

P0

  1. fix(mask) — key-name masking was dead code. KeyNameIsSensitive had no callers, and Mask(value) only received the value. DB_PASSWORD=correcthorsebatterystaple (no digits, no prefix) was returned verbatim by get, list, the MCP get tool and secret://secrets. New MaskKey(key, value) applies the key-name rule first; all display paths use it.
  2. fix(mcp) — put was an arbitrary-file read and unlink. The agent chose value_file; the server read it, then os.Removed it. An injected agent could read e.g. a config file into the store and read it back via get, and delete any file the user could write. Value files must now resolve inside <store>/inbox; symlink and .. escapes are refused. keysmith inbox prints the path.

P1

  1. fix(store) — lost writes. Read-modify-write with no lock: 20 concurrent set calls stored 12 keys. Writers now take an exclusive flock on <store>/store.lock (key init included). Temp files get a unique name. randomSecret switched to rejection sampling (the modulo bias was a one-line fix while in the file).
  2. fix(redeem) — --allow-path prefix escape. --target https://hooks.slack.com/services/../../admin satisfied a /services/ binding. Paths with ./.. segments, encoded slashes (%2f, %5c), backslashes and ..; path parameters are now refused; .well-known still parses.
  3. fix(scan) — the leak report printed the leaked secret. Now redacted (+DB_PASSWORD= Su******ue) via mask.Redact, which always masks.
  4. fix(http) — unauthenticated HTTP transports. -http :8080 (the README example) exposed delete/rotate to the LAN. A non-loopback bind now requires -http-token; requests must carry Authorization: Bearer <token> or get 401. README examples bind 127.0.0.1.

Verification

  • gofmt -l . clean, go vet ./... clean, go test ./... all packages pass (internal/mcp gained its first tests)
  • python3 e2e_test.py — passes, including a new assertion that a value_file outside the inbox is refused and the file survives
  • python3 verify_redeem.py — all 39 checks pass (43 on Linux with the /proc checks)
  • python3 scripts/check-doc-parity.py — all 4 pairs in sync
  • Manual: 20/20 concurrent set kept; services/../../admin refused; anonymous HTTP request → 401; weak passphrase under DB_PASSWORD now masked

Breaking changes (0.5.0)

  • put refuses value_file outside <store>/inbox (agents should call keysmith inbox)
  • -http on a non-loopback address requires -http-token
  • keysmith scan output no longer includes the matched value

CHANGELOG.md is updated under 0.5.0.

KeyNameIsSensitive existed but nothing called it, so a sensitive key holding
a passphrase without digits (DB_PASSWORD=correcthorsebatterystaple) was
returned verbatim by get/list, the MCP get tool and the MCP resource.

MaskKey applies the key-name rule first and falls back to the value
heuristics. store.List, the MCP get handler, the CLI get/set/vault commands,
the Vault KV listing and the leak-scan report now use it.
put read any path the agent supplied and unlinked it afterwards, which made
the tool an arbitrary-file read (store a file, then read it back with get when
the value does not look secret) and an arbitrary-file delete.

Value files now have to resolve inside <store>/inbox: symlink escapes,
relative escapes and absolute paths elsewhere are refused. The inbox is
created 0700, `keysmith inbox` prints it, and the tool description tells the
agent where to write. e2e_test.py asserts the refusal and that the outside
file survives.
Set/Delete/Rotate did a read-modify-write of the whole map with no lock. The
MCP server and the CLI share the store, so an agent writing while a human runs
`keysmith set` silently lost one of the writes: 20 concurrent `set` calls
stored only 12 keys.

Writers now take an exclusive flock on <store>/store.lock (unix; a no-op
single-process fallback elsewhere), including key initialization, so two
processes cannot generate competing identities. Temp files use a unique name
per write, and randomSecret uses rejection sampling instead of a biased
modulo, since the bias was easy to remove while touching the file.
--allow-path matched the parsed path with a string prefix, but url.Parse
keeps dot segments, so a token bound to /services/ was redeemed for
https://hooks.slack.com/services/../../admin. Encoded separators (%2f,
%5c) and the '..;' path-parameter form were accepted the same way.

ParseTarget now refuses '.'/'..' segments, encoded slashes, backslashes and
dot segments hidden behind path parameters. '.well-known' still parses.
`keysmith scan` printed the matched line verbatim, so the secret it was
warning about landed in the terminal, the CI log, and any issue the report
was pasted into.

Lines are now redacted: the key name and diff marker stay, the value is
masked with mask.Redact, which always masks instead of consulting the
heuristics. The test that asserted the value appears now asserts it does not.
The HTTP/SSE and Streamable transports had no authentication at all, while
the tools include delete and rotate. Binding `-http :8080` (every interface),
as the README showed, exposed them to the LAN.

A non-loopback bind now requires `-http-token`; when set, every request must
carry `Authorization: Bearer <token>` (constant-time comparison) or gets 401.
The README examples bind 127.0.0.1 and document the flag.
@sscodeai
sscodeai merged commit dff74f9 into dev Oct 21, 2026
4 checks passed
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