diff --git a/AGENTS.ja.md b/AGENTS.ja.md index d41a5c3..bc606b2 100644 --- a/AGENTS.ja.md +++ b/AGENTS.ja.md @@ -15,8 +15,8 @@ 1. `secrets.enc` は平文の key material を決して含んではいけません。age で暗号化します。 2. `key.txt` は `0600`、store dir は `0700` でなければなりません。 -3. すべての MCP tool result と resource はマスク済みでなければなりません。tool result に平文を出してはいけません。 -4. `put` は `value_file` パスを受け取り、それを読み、読み取り後に削除しなければなりません。 +3. すべての MCP tool result と resource はマスク済みでなければなりません。tool result に平文を出してはいけません。`mask.MaskKey` を使い、機密キー名 (DB_PASSWORD, GITHUB_TOKEN...) の場合は値がヒューリスティックに一致しなくてもマスクしてください。 +4. `put` は **`/inbox` 内** の `value_file` パスを受け取り、それを読み、読み取り後に削除しなければなりません。inbox の外のパス (外を指す symlink を含む) は拒否します。引数はエージェントが制御するためです。 5. マスキング規則は `internal/mask` にあります。entropy/prefix/URL segmentation のテストを green に保ってください。 6. **redemption は fail-closed です** (`internal/redeem`、docs/THREAT-MODEL.md)。未知のセッション、期限切れセッション、そのセッションで発行されていないキー名、値の欠落または空値、不正な予約プレフィックス、`argv` 内のトークン、audit log の書き込み失敗。いずれの場合もコマンドを起動しません。トークンをそのまま転送するフォールバックも、平文を転送するフォールバックも存在せず、エラーメッセージに値を含めてはいけません。 7. redemption は `argv` に値を置かず、値も引数リスト全体もログに残しません (`/audit.log` はキー名のみを記録します)。 diff --git a/AGENTS.md b/AGENTS.md index 9e85a45..76f0b53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,12 @@ agents that develop it. 1. `secrets.enc` must never contain plaintext key material (age-encrypted). 2. `key.txt` must be `0600`, store dir `0700`. 3. Every MCP tool result and resource must be masked — no plaintext in - tool results, ever. -4. `put` must accept a `value_file` path, read it, and delete it after. + tool results, ever. Use `mask.MaskKey`, so a sensitive key name + (DB_PASSWORD, GITHUB_TOKEN, ...) masks the value even when the value itself + does not match a heuristic. +4. `put` must accept a `value_file` path **inside `/inbox`**, read it, + and delete it after. Paths outside the inbox (including symlinks that + resolve outside) are refused: the argument is agent-controlled. 5. Masking rules live in `internal/mask` — keep the entropy/prefix/URL segmentation tests green. 6. **Redemption fails closed** (`internal/redeem`, docs/THREAT-MODEL.md). On any diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe7c59..2ed5f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,24 @@ All notable changes to this project are documented here. The format follows merging now have unit tests; CI runs `gofmt`, `go vet`, `go test`, `e2e_test.py`, `verify_redeem.py` and the doc-parity check on every push. +### Fixed +- Masking now uses the key name: `DB_PASSWORD=correcthorsebatterystaple` is + masked by `get`, `list`, the MCP `get` tool and the MCP resource, instead of + being returned verbatim. `KeyNameIsSensitive` had no callers before. +- `put` only accepts a `value_file` inside `/inbox`; an agent-supplied + path outside it was an arbitrary-file read and unlink. `keysmith inbox` + prints the directory, and symlink escapes are refused. +- Store writes take an exclusive lock (`/store.lock`), so the MCP server + and the CLI can no longer lose each other's updates (20 concurrent `set` + calls used to store 12 keys). +- `--target` paths containing `.`/`..` segments, encoded slashes or + backslashes are refused, closing a `--allow-path` prefix escape such as + `/services/../../admin`. +- An HTTP listener on a non-loopback address requires `-http-token`; requests + without a matching `Authorization: Bearer` get 401. +- `keysmith scan` no longer prints the leaked value itself in its report. +- `randomSecret` uses rejection sampling instead of a biased modulo. + ## 0.4.0 — 2026-09-14 ### Added diff --git a/README.ja.md b/README.ja.md index 90e2ed1..800e1e2 100644 --- a/README.ja.md +++ b/README.ja.md @@ -20,7 +20,7 @@ Keysmith は、**多層セキュリティモデル**でこれを解決します |---|---|---| | 暗号化された保存 | 保存時に age (X25519) で暗号化 | ディスク上の平文。コンテキストに漏れても blob は暗号文なので無害 | | マスク済みビュー | resources/tools が `sk******ij` 形式で返す | 認証情報がエージェントのコンテキストに入ること | -| ハンドル渡し | `put` は transcript ではなく一時ファイル引数から値を読む | ツール呼び出し transcript 内の平文 | +| ハンドル渡し | `put` は transcript ではなくストアの inbox にあるファイルから値を読む | ツール呼び出し transcript 内の平文 | | 自己修復ローテーション | `scan --rotate` が漏えいを検出し、漏れた値を無効化 | 古い漏えい済み認証情報が使われ続けること | | 短 TTL (Vault) | 動的 DB 認証情報は 1 時間で期限切れ | 漏えいした認証情報の価値が残ること | | Redemption | `keysmith run` がトークンをローカルで差し替え、子プロセスの環境変数へ渡す | 認証情報を「使う」必要があるエージェントが平文を出力してしまうこと。`argv`、transcript、ログ内の平文 | @@ -47,17 +47,23 @@ KEYSMITH_STORE=/path/to/store keysmith リモートエージェント向けに HTTP/SSE で提供します。 ```sh -keysmith -store ~/.keysmith -http :8080 +keysmith -store ~/.keysmith -http 127.0.0.1:8080 # endpoint: http://localhost:8080/sse ``` MCP 2025 Streamable HTTP で提供します。単一 POST エンドポイントのステートレス方式です。 ```sh -keysmith -store ~/.keysmith -http :8080 -streamable +keysmith -store ~/.keysmith -http 127.0.0.1:8080 -streamable # endpoint: http://localhost:8080/mcp ``` +HTTP リスナー自体には認証がないため、keysmith は `-http-token` なしで非ループバックアドレスにバインドしません。トークンを指定した場合、すべてのリクエストに `Authorization: Bearer ` が必要です。 + +```sh +keysmith -store ~/.keysmith -http 192.168.1.10:8080 -http-token "$(openssl rand -hex 16)" -streamable +``` + HashiCorp Vault をバックエンドとして使用します。短 TTL の動的シークレットに対応します。 ```sh @@ -95,6 +101,7 @@ keysmith set API_KEY < value.txt # stdin から値を読み、shell history keysmith rotate API_KEY 32 # 強力な新しいシークレットを生成して保存 keysmith delete API_KEY # キーを削除 keysmith scan [--rotate] [repo-dir] # git 履歴から漏えいしたシークレットをスキャン +keysmith inbox # MCP の put が値ファイルを受け取るディレクトリ keysmith token API_KEY # セッションに紐づくプレースホルダートークンを発行 keysmith token API_KEY --allow-host api.example.test --allow-path /v1/ # ...宛先に結合する(下記参照) @@ -134,7 +141,7 @@ keysmith -vault http://127.0.0.1:8200 vault-db-creds app-role # 短 TTL |---|---|---| | `list` | すべてのキーとマスク済み値 | 値は平文にならない | | `get` | 1 つのキーのマスク済み値 | 値は平文にならない | -| `put` | シークレットを保存 | 値は `value_file` 引数から読み、一時ファイルは自動削除 | +| `put` | シークレットを保存 | `value_file` はストアの inbox 内のみ受け付け、読み取り後に削除 | | `rotate` | 強力なランダムシークレットを生成して保存 | マスク済み値のみを返す | | `delete` | キーを削除 | - | @@ -192,9 +199,9 @@ flowchart TB - **保存時**: 常に age (X25519) で暗号化され、armor 形式です。平文ファイルはディスク上に存在しません。atomic write (temp + rename) と `0600` 権限を使います。 - **コンテキスト内**: マスク済み値のみです。マスキングは先頭/末尾 2 文字を残し (`sk******ij`)、認証情報を識別できるが露出しない形にします。 -- **Transcript 内**: `put` は平文を引数として受け取りません。一時ファイルパスを読み、読み取り後にファイルを削除します。 +- **Transcript 内**: `put` は平文を引数として受け取りません。ファイルパスを読み、ストアの inbox (`keysmith inbox`) 内のパスのみ受け付け、読み取り後にファイルを削除します。inbox の外は拒否されるため、任意ファイルの読み取りや削除には使えません。 - **使用時**: `keysmith run` はセッションに紐づくトークンをローカルで解決し、値を子プロセスの環境変数へ渡します。値は `argv`、audit log、モデルへのリクエストに到達しません。失敗時はコマンド起動前に中止し、平文フォールバックもトークンのそのまま転送も行いません。結合済みトークンは `--target` も満たす必要があり、宣言された宛先は audit 行に記録されます。 -- **マスキング規則**: キー名マーカー (SECRET/TOKEN/PASSWORD/API_KEY/DSN...)、既知の値プレフィックス (sk-, ghp_, glpat-, xoxb-, JWT...)、高エントロピーな英数字列 (英字と数字が混在する 20 文字以上、Shannon entropy 3.5 以上) を使います。URL 形状の値はセグメントごとにマスクされます。userinfo のパスワードは常にマスクされ、高エントロピーな path/query はマスクされ、host/port は残ります。timeout や retry などの純数字値はマスクされません。 +- **マスキング規則**: キー名マーカー (SECRET/TOKEN/PASSWORD/API_KEY/DSN...)、既知の値プレフィックス (sk-, ghp_, glpat-, xoxb-, JWT...)、高エントロピーな英数字列 (英字と数字が混在する 20 文字以上、Shannon entropy 3.5 以上) を使います。キー名が認証情報を示す場合は、値がヒューリスティックに一致しなくても必ずマスクします (数字を含まないパスフレーズもシークレットです)。機密でないキー (DEBUG, PORT) の設定値はそのまま読めます。URL 形状の値はセグメントごとにマスクされます。userinfo のパスワードは常にマスクされ、高エントロピーな path/query はマスクされ、host/port は残ります。timeout や retry などの純数字値はマスクされません。 ## 開発 diff --git a/README.md b/README.md index e1eabe8..1413e13 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Keysmith fixes this with a **layered security model**: |---|---|---| | Encrypted residency | age (X25519) encrypted store at rest | Plaintext on disk; even a leaked context is harmless (blobs are ciphertext) | | Masked views | resources/tools return `sk******ij` form | Credentials entering agent context | -| Handle-passing | `put` reads value from a temp file arg, not the transcript | Plaintext in tool-call transcripts | +| Handle-passing | `put` reads the value from a file in the store inbox, not from the transcript | Plaintext in tool-call transcripts | | Self-healing rotation | `scan --rotate` detects leaks, kills the leaked value | Stale leaked credentials keep working | | Short-TTL (Vault) | dynamic DB creds expire in 1h | Leaked credentials become worthless | | Redemption | `keysmith run` substitutes a token locally, into the child's environment | An agent that must *use* a credential being tempted to print it; plaintext in `argv`, transcripts, or logs | @@ -58,17 +58,25 @@ KEYSMITH_STORE=/path/to/store keysmith Serve over HTTP/SSE for remote agents: ```sh -keysmith -store ~/.keysmith -http :8080 +keysmith -store ~/.keysmith -http 127.0.0.1:8080 # endpoint: http://localhost:8080/sse ``` Serve MCP 2025 Streamable HTTP (single POST endpoint, stateless): ```sh -keysmith -store ~/.keysmith -http :8080 -streamable +keysmith -store ~/.keysmith -http 127.0.0.1:8080 -streamable # endpoint: http://localhost:8080/mcp ``` +An HTTP listener has no authentication of its own, so keysmith refuses to bind +a non-loopback address unless `-http-token` is set. With a token, every request +must carry `Authorization: Bearer `: + +```sh +keysmith -store ~/.keysmith -http 192.168.1.10:8080 -http-token "$(openssl rand -hex 16)" -streamable +``` + Use HashiCorp Vault as the backend (dynamic short-TTL secrets): ```sh @@ -106,6 +114,7 @@ keysmith set API_KEY < value.txt # value from stdin, no shell-history leak keysmith rotate API_KEY 32 # generate + store new strong secret keysmith delete API_KEY # remove a key keysmith scan [--rotate] [repo-dir] # scan git history for leaked secrets +keysmith inbox # directory the MCP put tool accepts files from keysmith token API_KEY # issue a session-bound placeholder token keysmith token API_KEY --allow-host api.example.test --allow-path /v1/ # ...bound to a destination (see below) @@ -157,7 +166,7 @@ keysmith -vault http://127.0.0.1:8200 vault-db-creds app-role # dynamic sh |---|---|---| | `list` | All keys with masked values | Values never plaintext | | `get` | Single key, masked value | Values never plaintext | -| `put` | Store a secret | Value read from `value_file` arg, temp file auto-removed | +| `put` | Store a secret | Reads `value_file` from the store inbox only, then deletes it | | `rotate` | Generate + store a new strong random secret | Returns masked value only | | `delete` | Remove a key | — | @@ -220,10 +229,12 @@ itself, transports decide how agents connect. - **At rest**: always age-encrypted (X25519), armor format. A plaintext file never exists on disk. Atomic writes (temp + rename), `0600` perms. +- **In transcripts**: `put` never takes the plaintext as an argument — it + reads a file path, and only inside the store inbox (`keysmith inbox`); the + file is deleted after it is read. A path outside the inbox is refused, so the + tool cannot be used as an arbitrary-file read or unlink. - **In context**: masked values only. Masking keeps first/last 2 chars (`sk******ij`) so credentials are distinguishable without being revealed. -- **In transcripts**: `put` never takes the plaintext as an argument — it - reads a temp file path and deletes the file after. - **At use time**: `keysmith run` redeems a session-bound token locally and places the value in the child's environment. Values never reach `argv`, the audit log, or a model request; every failure aborts before the command starts @@ -233,6 +244,9 @@ itself, transports decide how agents connect. - **Masking rules**: key-name markers (SECRET/TOKEN/PASSWORD/API_KEY/DSN...), known value prefixes (sk-, ghp_, glpat-, xoxb-, JWT...), and high-entropy alphanumeric runs (≥20 chars mixing letters+digits, Shannon entropy ≥3.5). + A sensitive key name forces masking even when the value does not match a + value heuristic — a passphrase without digits is still a secret. Plain + configuration under a non-sensitive key (DEBUG, PORT) stays readable. URL-shaped values are masked per-segment: userinfo password always masked, high-entropy path/query runs masked, host/port stay clear. Pure-numeric values (timeouts, retries) are never masked. diff --git a/cmd/keysmith/main.go b/cmd/keysmith/main.go index d320cd1..c063e80 100644 --- a/cmd/keysmith/main.go +++ b/cmd/keysmith/main.go @@ -20,11 +20,13 @@ package main import ( "context" + "crypto/subtle" "errors" "flag" "fmt" "io" "log" + "net" "net/http" "os" "os/exec" @@ -46,6 +48,7 @@ var version = "0.5.0" func main() { storeDir := flag.String("store", defaultStoreDir(), "directory holding age-encrypted secrets (key.txt + secrets.enc)") httpAddr := flag.String("http", "", "serve MCP over HTTP/SSE on this address (e.g. :8080) instead of stdio") + httpToken := flag.String("http-token", "", "require this bearer token on every HTTP request (required when -http binds a non-loopback address)") streamable := flag.Bool("streamable", false, "with -http: use Streamable HTTP (MCP 2025) instead of SSE") vaultAddr := flag.String("vault", "", "HashiCorp Vault address (e.g. http://127.0.0.1:8200) — use Vault as backend") showVer := flag.Bool("version", false, "print version and exit") @@ -60,7 +63,7 @@ func main() { // No subcommand → MCP server mode (stdio or HTTP/SSE). if len(args) == 0 { - runMCPServer(*storeDir, *httpAddr, *vaultAddr, *streamable) + runMCPServer(*storeDir, *httpAddr, *vaultAddr, *httpToken, *streamable) return } @@ -70,7 +73,7 @@ func main() { } } -func runMCPServer(storeDir, httpAddr, vaultAddr string, streamable bool) { +func runMCPServer(storeDir, httpAddr, vaultAddr, httpToken string, streamable bool) { st, err := store.New(storeDir) if err != nil { log.Fatalf("store init: %v", err) @@ -84,6 +87,9 @@ func runMCPServer(storeDir, httpAddr, vaultAddr string, streamable bool) { // Default = SSE transport (GET /sse + POST endpoint). // With -streamable = MCP 2025 Streamable HTTP (single POST endpoint, JSON). if httpAddr != "" { + if err := checkHTTPBinding(httpAddr, httpToken); err != nil { + log.Fatalf("http: %v", err) + } var handler http.Handler if streamable { handler = sdk.NewStreamableHTTPHandler(func(*http.Request) *sdk.Server { return srv.Handler() }, @@ -95,6 +101,10 @@ func runMCPServer(storeDir, httpAddr, vaultAddr string, streamable bool) { log.Printf("keysmith serving MCP over SSE on %s", httpAddr) log.Printf(" endpoint: http://localhost%s/sse", httpAddr) } + if httpToken != "" { + handler = requireBearer(handler, httpToken) + log.Printf(" authentication: Authorization: Bearer is required") + } if err := http.ListenAndServe(httpAddr, handler); err != nil { log.Fatalf("http server: %v", err) } @@ -157,7 +167,7 @@ func runCLI(storeDir, vaultAddr string, args []string) error { if unsafe { fmt.Println(val) } else { - fmt.Println(mask.Mask(val)) + fmt.Println(mask.MaskKey(key, val)) } return nil @@ -179,7 +189,7 @@ func runCLI(storeDir, vaultAddr string, args []string) error { if err := st.Set(key, value); err != nil { return err } - fmt.Printf("stored %s (masked: %s)\n", key, mask.Mask(value)) + fmt.Printf("stored %s (masked: %s)\n", key, mask.MaskKey(key, value)) return nil case "rotate": @@ -230,6 +240,15 @@ func runCLI(storeDir, vaultAddr string, args []string) error { fmt.Print(leakscan.Format(results)) return nil + case "inbox": + // keysmith inbox — print the directory `put` accepts value files from. + dir := filepath.Join(storeDir, mcp.InboxDirName) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + fmt.Println(dir) + return nil + case "vault-kv-get": // keysmith vault-kv-get KEY (masked by default, --unsafe for plaintext) if len(rest) < 1 { @@ -246,7 +265,7 @@ func runCLI(storeDir, vaultAddr string, args []string) error { if containsFlag(rest, "--unsafe") { fmt.Println(val) } else { - fmt.Println(mask.Mask(val)) + fmt.Println(mask.MaskKey(rest[0], val)) } return nil @@ -271,7 +290,7 @@ func runCLI(storeDir, vaultAddr string, args []string) error { if err := vc.SetKV(context.Background(), rest[0], value); err != nil { return err } - fmt.Printf("stored %s in vault (masked: %s)\n", rest[0], mask.Mask(value)) + fmt.Printf("stored %s in vault (masked: %s)\n", rest[0], mask.MaskKey(rest[0], value)) return nil case "vault-kv-list": @@ -302,7 +321,7 @@ func runCLI(storeDir, vaultAddr string, args []string) error { return err } fmt.Printf("username: %s\n", creds.Username) - fmt.Printf("password: %s (masked)\n", mask.Mask(creds.Password)) + fmt.Printf("password: %s (masked)\n", mask.MaskKey("DB_PASSWORD", creds.Password)) fmt.Printf("lease_id: %s\n", creds.LeaseID) fmt.Printf("ttl: %ds (auto-expires — leaked cred is harmless)\n", creds.LeaseTTL) fmt.Printf("renewable: %v\n", creds.Renewable) @@ -380,6 +399,7 @@ USAGE: keysmith rotate KEY [len] # generate + store new strong secret keysmith delete KEY # remove a key keysmith scan [--rotate] [repo-dir] # scan git history for leaked secrets + keysmith inbox # print the directory MCP put accepts value files from keysmith token NAME [--ttl 1h] [--allow-host H] [--allow-path P] [--allow-header HN] # issue a session-bound placeholder token keysmith run [--session SID] [--target URL] --env KEY='