Skip to content

Instantly share code, notes, and snippets.

@snaga
Last active September 6, 2026 05:15
Show Gist options
  • Select an option

  • Save snaga/5a0d5275e8f0817c60414254cb1677ae to your computer and use it in GitHub Desktop.

Select an option

Save snaga/5a0d5275e8f0817c60414254cb1677ae to your computer and use it in GitHub Desktop.
Windows CLI ツールにおけるセキュアなクレデンシャル管理ベストプラクティス

Windows CLI ツールにおけるセキュアなクレデンシャル管理ベストプラクティス

本ドキュメントは、Go 言語を中心とした Windows 向け CLI ツールにおいて、API トークンやパスワード等の機密情報(Credentials)を安全・堅牢に扱い、かつ AI コーディングエージェントや CI/CD とも共存させるための設計・実装ナレッジを体系化した実践ガイドです。


1. 概要と基本方針:なぜ OS ネイティブ管理なのか?

従来の CLI ツールでよく使われてきた「平文ファイル(.env 等)」や「コマンドライン引数(--token 等)」には、重大なセキュリティリスクが存在します。

❌ 避けるべきアンチパターン

  1. 平文ファイル (.env, config.json) のリスク:
    • .gitignore の設定漏れによる GitHub/GitLab への誤コミット・外部流出。
    • ディスク上の平文保存による他ユーザーや悪意あるプロセスからの不正読み取り。
    • プロジェクトフォルダ全体のバックアップや共有時の意図しない混入。
  2. コマンドライン引数 (--token <PAT>) のリスク:
    • シェルのコマンド実行履歴(PowerShell の ConsoleHost_history.txt や Bash の .bash_history)に平文トークンが永続化される。
    • Get-Process、Task Manager、ps aux 等により、同マシン上で動く別プロセスから引数が丸見えになる。

✅ 採用すべきアーキテクチャ: Windows 資格情報マネージャー (WCM)

  • Windows OS が提供する暗号化ストレージ(DPAPI: Data Protection API ベース)。
  • ログインユーザーの秘密鍵によって透過的に暗号化され、他ユーザーや未認証プロセスからの直接抽出が困難。
  • コマンドライン(cmdkey)からも、Windows 標準 GUI(コントロールパネルの「資格情報マネージャー」)からも閲覧・更新・削除が可能。
  • ※容量制限: Win32 API の仕様上、CredentialBlob の最大サイズは CRED_MAX_CREDENTIAL_BLOB_SIZE(2560〜5120 バイト)に制限されています。通常の API トークンやパスワードには十分ですが、巨大な秘密鍵(RSA 4096bit PEM 等)の保管には適さない点に留意してください。

2. 資格情報解決の優先順位(The Priority Pyramid)

CLI が認証情報を解決する際は、以下の優先順位(フォールバックチェーン)を厳守します。

[優先度: 高]
  1. 一時デバッグ / CI・CD 用環境変数 (例: DEBUG_GITHUB_PAT)
        │ (未設定の場合)
        ▼
  2. Windows 資格情報マネージャー (Target: <app-name>:<credential-key>)
        │ (未登録の場合)
        ▼
  3. 自己修正可能なエラー(Actionable Error)を出力して異常終了 (Exit 1)
[優先度: 低]

💡 設計のポイント

  • 環境変数は DEBUG_ プレフィックスを推奨:
    • 一般的な名前(GITHUB_TOKEN 等)にしてしまうと、意図しないグローバル環境変数や親プロセスの値が誤って読み込まれる事故が起きます。
    • あえて DEBUG_<APP>_PAT のような名前にすることで、「意図的な一時実行や CI/CD でのみ利用する」という境界を明示できます。
  • ターゲット名前空間の分離:
    • WCM のターゲット名(Target Name)は全アプリ共通の空間です。
    • 必ず <app-name>:<credential-key>(例: github-issue-cli:github_pat)のようにプレフィックスを付け、他アプリとの名前衝突を防ぎます。

3. 【最重要】実装上の落とし穴と対策 (Gotchas & Solutions)

🚨 1. cmdkey の UTF-16LE / Null Byte トラップ

問題の原因

Windows 標準の cmdkey /generic:<Target> /user:<User> /pass:<Password> コマンドでパスワードを登録すると、内部的に UTF-16LE(各 ASCII 文字の後ろに null バイト 0x00 が挟まる形式) で保存されます。 一方、GUI の資格情報マネージャーから手動登録した場合は、通常の UTF-8 / ASCII バイト列として保存されます。

Go の wincred ライブラリ等で cred.CredentialBlob をそのまま string(cred.CredentialBlob) と文字列キャストすると、cmdkey 経由のトークンに 0x00 が混入し、HTTP リクエスト送信時に以下のエラーでクラッシュします:

net/http: invalid header field value for "Authorization"

解決策:自動判定デコーダーの実装

バイト列が偶数長かつ、奇数バイト位置に null バイト(0x00)が多く含まれているか(または末尾の null バイトを除去した上で null バイトが存在するか)をチェックし、UTF-16LE の場合は unicode/utf16 パッケージを用いてデコードします。

// decodeCredentialBlob は、CredentialBlob が UTF-16LE(cmdkey登録時)か UTF-8かを
// 自動判別してクリーンな文字列にデコードします。
func decodeCredentialBlob(blob []byte) string {
	if len(blob) == 0 {
		return ""
	}

	// 末尾の null バイト(C文字列終端)をトリム
	trimmed := bytes.TrimRight(blob, "\x00")
	if len(trimmed) == 0 {
		return ""
	}

	// 偶数長であり、内部に null バイトが存在する場合(ASCII文字列が UTF-16LE で保存された場合)は UTF-16LE としてデコード
	if len(blob)%2 == 0 && bytes.IndexByte(trimmed, 0x00) != -1 {
		u16 := make([]uint16, len(blob)/2)
		for i := 0; i < len(u16); i++ {
			u16[i] = binary.LittleEndian.Uint16(blob[i*2 : i*2+2])
		}
		decoded := string(utf16.Decode(u16))
		return strings.TrimSpace(strings.TrimRight(decoded, "\x00"))
	}

	// 通常の UTF-8 / ASCII
	return strings.TrimSpace(string(trimmed))
}

🛡️ 2. シェル履歴に残さない安全な登録手順

ユーザーやドキュメント向けに案内する際、cmdkey /pass:ghp_xxxx を直接打たせるとシェル履歴に残ってしまいます。PowerShell の対話入力(マスク入力)を推奨します。 PowerShell では、オブジェクトのプロパティ呼び出しをコマンドライン引数に渡す際、サブ式 $() で囲むか、一度変数に受ける必要があります。

# 推奨: 画面・履歴にトークンを残さない安全な登録
$cred = Get-Credential -UserName "any" -Message "GitHub PAT をパスワード欄に入力してください"
$pass = $cred.GetNetworkCredential().Password
cmdkey /generic:github-issue-cli:github_pat /user:$cred.UserName /pass:$pass
# または 1 行で書く場合:
# cmdkey /generic:github-issue-cli:github_pat /user:$cred.UserName /pass:$($cred.GetNetworkCredential().Password)

🤖 3. エージェント向け自己修正エラー(Actionable Error)

トークンが見つからない場合、単に「認証エラー」と返すのではなく、その場でコピペして実行すれば即復旧できるコマンド例をエラーメッセージに含めます。

var ErrAuthentication = errors.New(`[Authentication Error] GitHub Personal Access Token is invalid or not provided.
To fix this, please register your PAT using Windows Credential Manager:
  cmdkey /generic:github-issue-cli:github_pat /user:any /pass:<your_token>
Or set the environment variable DEBUG_GITHUB_PAT=<your_token>.`)

これにより、AI エージェントが実行した場合でも、エラーメッセージを読んで自律的に環境変数設定や登録処理を行えるようになります(Agent DX)。


🧪 4. テスト容易性(モック境界の分離)

Windows 資格情報マネージャーは OS のネイティブ API を呼び出すため、そのままでは CI 環境(Linux 等)やローカルテストで実行できません。 読み込み関数をパッケージレベルの変数として外部から差し替え可能にしておきます。

// wincred の呼び出しを関数変数にしてテスト時にモック可能にする
var getGenericCredential = wincred.GetGenericCredential

// テストコードでの利用例
func TestResolveToken_WCM(t *testing.T) {
    orig := getGenericCredential
    defer func() { getGenericCredential = orig }()

    getGenericCredential = func(target string) (*wincred.GenericCredential, error) {
        return &wincred.GenericCredential{
            CredentialBlob: []byte("mock_token"),
        }, nil
    }

    token, source, err := resolveToken()
    // アサーション...
}

4. コピペで使える汎用実装テンプレート (cred.go)

以下のコードは、他プロジェクトでそのまま再利用できるテンプレートです。 依存ライブラリ: github.com/danieljoos/wincred

package cli

import (
	"bytes"
	"encoding/binary"
	"errors"
	"os"
	"strings"
	"unicode/utf16"

	"github.com/danieljoos/wincred"
)

// アプリケーション固有の定数(適宜変更)
const (
	TargetCredentialName = "my-app:api_token"
	EnvDebugToken        = "DEBUG_MY_APP_TOKEN"
)

var ErrAuthentication = errors.New("[Authentication Error] API Token is invalid or not provided.\n" +
	"To fix this, please register your token using Windows Credential Manager:\n" +
	"  cmdkey /generic:" + TargetCredentialName + " /user:any /pass:<your_token>\n" +
	"Or set the environment variable " + EnvDebugToken + "=<your_token>.")

// テスト時に差し替え可能な関数変数
var getGenericCredential = wincred.GetGenericCredential

// ResolveToken は API トークンと取得元ソース名を返します。
func ResolveToken() (token string, source string, err error) {
	// 1. デバッグ用環境変数のチェック
	if envVal := strings.TrimSpace(os.Getenv(EnvDebugToken)); envVal != "" {
		return envVal, "environment variable (" + EnvDebugToken + ")", nil
	}

	// 2. Windows 資格情報マネージャーのチェック
	cred, err := getGenericCredential(TargetCredentialName)
	if err == nil && cred != nil {
		tokenVal := decodeCredentialBlob(cred.CredentialBlob)
		if tokenVal != "" {
			return tokenVal, "Windows Credential Manager (" + TargetCredentialName + ")", nil
		}
	}

	// 3. どちらからも取得できない場合は自己修正エラーを返却
	return "", "", ErrAuthentication
}

func decodeCredentialBlob(blob []byte) string {
	if len(blob) == 0 {
		return ""
	}

	trimmed := bytes.TrimRight(blob, "\x00")
	if len(trimmed) == 0 {
		return ""
	}

	if len(blob)%2 == 0 && bytes.IndexByte(trimmed, 0x00) != -1 {
		u16 := make([]uint16, len(blob)/2)
		for i := 0; i < len(u16); i++ {
			u16[i] = binary.LittleEndian.Uint16(blob[i*2 : i*2+2])
		}
		decoded := string(utf16.Decode(u16))
		return strings.TrimSpace(strings.TrimRight(decoded, "\x00"))
	}

	return strings.TrimSpace(string(trimmed))
}

5. まとめ・チェックリスト

新しい Windows CLI ツールを作る際は、以下のチェックリストを活用してください:

  • 平文 .env ファイル探索や、引数での --token フラグを設けていないか?
  • WCM のターゲット名は衝突しないようプレフィックス(<app>:<key>)を付けているか?
  • 一時実行・CI/CD 用に DEBUG_<APP>_<TOKEN> 環境変数のフォールバックを用意しているか?
  • cmdkey 登録時の UTF-16LE(null バイト混入)を考慮したデコード処理を実装しているか?
  • トークン未設定エラーの文面に、具体的な復旧コマンド(cmdkey 例)を提示しているか?
  • WCM 読み込み処理をモック可能にし、テストで OS ストレージに依存しない設計にしているか?
  • ユーザー向けドキュメントで、履歴に残らない安全な登録方法(PowerShell Get-Credential 等)を案内しているか?

6. グラウンディング参照リソース

本ドキュメントの設計判断・技術仕様は、以下の公式ドキュメントおよび信頼できる技術仕様に基づいてグラウンディングされています。

  1. Microsoft Learn: CREDENTIALA structure (wincred.h)
    • CREDENTIALA structure
    • 検証内容: CredentialBlob のバイト長制限(CRED_MAX_CREDENTIAL_BLOB_SIZE = 5120 bytes / 2560 bytes)および DPAPI による保護仕様の確認。
  2. Microsoft Learn: cmdkey コマンドリファレンス
    • cmdkey command reference
    • 検証内容: /generic, /user, /pass による Generic Credential の作成・永続化動作(PersistLocalMachine)の確認。
  3. GitHub: danieljoos/wincred
    • danieljoos/wincred Repository
    • 検証内容: Go 言語による Win32 Credential Manager API バインディングの実装、およびワイド文字列と CredentialBlob の UTF-16LE 相互運用の確認。
  4. GitHub: jaraco/keyring (Python Keyring)
    • jaraco/keyring Windows Backend
    • 検証内容: Windows Credential Manager バックエンドにおける utf-16le デコードと null-terminated バイト処理の定石パターンの確認。
  5. MITRE ATT&CK: Credentials in Files / Command-Line Parameters
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment