本ドキュメントは、Go 言語を中心とした Windows 向け CLI ツールにおいて、API トークンやパスワード等の機密情報(Credentials)を安全・堅牢に扱い、かつ AI コーディングエージェントや CI/CD とも共存させるための設計・実装ナレッジを体系化した実践ガイドです。
従来の CLI ツールでよく使われてきた「平文ファイル(.env 等)」や「コマンドライン引数(--token 等)」には、重大なセキュリティリスクが存在します。
- 平文ファイル (
.env,config.json) のリスク:.gitignoreの設定漏れによる GitHub/GitLab への誤コミット・外部流出。- ディスク上の平文保存による他ユーザーや悪意あるプロセスからの不正読み取り。
- プロジェクトフォルダ全体のバックアップや共有時の意図しない混入。
- コマンドライン引数 (
--token <PAT>) のリスク:- シェルのコマンド実行履歴(PowerShell の
ConsoleHost_history.txtや Bash の.bash_history)に平文トークンが永続化される。 Get-Process、Task Manager、ps aux等により、同マシン上で動く別プロセスから引数が丸見えになる。
- シェルのコマンド実行履歴(PowerShell の
- Windows OS が提供する暗号化ストレージ(DPAPI: Data Protection API ベース)。
- ログインユーザーの秘密鍵によって透過的に暗号化され、他ユーザーや未認証プロセスからの直接抽出が困難。
- コマンドライン(
cmdkey)からも、Windows 標準 GUI(コントロールパネルの「資格情報マネージャー」)からも閲覧・更新・削除が可能。 - ※容量制限: Win32 API の仕様上、
CredentialBlobの最大サイズはCRED_MAX_CREDENTIAL_BLOB_SIZE(2560〜5120 バイト)に制限されています。通常の API トークンやパスワードには十分ですが、巨大な秘密鍵(RSA 4096bit PEM 等)の保管には適さない点に留意してください。
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)のようにプレフィックスを付け、他アプリとの名前衝突を防ぎます。
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))
}ユーザーやドキュメント向けに案内する際、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)トークンが見つからない場合、単に「認証エラー」と返すのではなく、その場でコピペして実行すれば即復旧できるコマンド例をエラーメッセージに含めます。
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)。
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()
// アサーション...
}以下のコードは、他プロジェクトでそのまま再利用できるテンプレートです。
依存ライブラリ: 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))
}新しい Windows CLI ツールを作る際は、以下のチェックリストを活用してください:
- 平文
.envファイル探索や、引数での--tokenフラグを設けていないか? - WCM のターゲット名は衝突しないようプレフィックス(
<app>:<key>)を付けているか? - 一時実行・CI/CD 用に
DEBUG_<APP>_<TOKEN>環境変数のフォールバックを用意しているか? -
cmdkey登録時の UTF-16LE(null バイト混入)を考慮したデコード処理を実装しているか? - トークン未設定エラーの文面に、具体的な復旧コマンド(
cmdkey例)を提示しているか? - WCM 読み込み処理をモック可能にし、テストで OS ストレージに依存しない設計にしているか?
- ユーザー向けドキュメントで、履歴に残らない安全な登録方法(PowerShell
Get-Credential等)を案内しているか?
本ドキュメントの設計判断・技術仕様は、以下の公式ドキュメントおよび信頼できる技術仕様に基づいてグラウンディングされています。
- Microsoft Learn: CREDENTIALA structure (wincred.h)
- CREDENTIALA structure
- 検証内容:
CredentialBlobのバイト長制限(CRED_MAX_CREDENTIAL_BLOB_SIZE= 5120 bytes / 2560 bytes)および DPAPI による保護仕様の確認。
- Microsoft Learn: cmdkey コマンドリファレンス
- cmdkey command reference
- 検証内容:
/generic,/user,/passによる Generic Credential の作成・永続化動作(PersistLocalMachine)の確認。
- GitHub: danieljoos/wincred
- danieljoos/wincred Repository
- 検証内容: Go 言語による Win32 Credential Manager API バインディングの実装、およびワイド文字列と
CredentialBlobの UTF-16LE 相互運用の確認。
- GitHub: jaraco/keyring (Python Keyring)
- jaraco/keyring Windows Backend
- 検証内容: Windows Credential Manager バックエンドにおける
utf-16leデコードと null-terminated バイト処理の定石パターンの確認。
- MITRE ATT&CK: Credentials in Files / Command-Line Parameters
- MITRE ATT&CK: T1552.003 (Bash / CLI History)
- 検証内容: コマンドライン引数渡しおよびシェル履歴への平文機密情報の漏洩リスクの脅威モデル確認。