0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Windows DPAPIとBitwarden Secrets ManagerでQiita APIトークンを安全に実行時注入する

0
Posted at

結論

WindowsでQiitaなどのAPI投稿を自動化するとき、APIトークンを.envへ保存する必要はない。

今回採用した構成は次のとおり。

  1. Windows DPAPIでBitwarden Password Managerの解除用パスワードを暗号化する
  2. Password Managerを非対話で解除する
  3. Password Managerから端末専用のBitwarden Secrets Manager access tokenを取得する
  4. Secrets ManagerからQIITA_TOKENを投稿プロセスへ実行時注入する
  5. 投稿終了後に環境変数とセッションを削除する

重要なのは、DPAPIが守るのは「Password Managerを解除するための情報」であり、Qiita APIトークンそのものではない点だ。

DPAPI blob
   ↓ Windowsユーザー単位で復号
Bitwarden Password Manager
   ↓ machine access token
Bitwarden Secrets Manager
   ↓ QIITA_TOKENを実行時注入
Qiita投稿プロセス

この境界を混同すると、「Bitwardenの自動解除は成功したのにAPI投稿できない」という状態になる。

なぜ.envへ保存しないのか

永続的な環境変数や平文.envは扱いやすい反面、次の経路から漏れる可能性がある。

  • Gitへの誤コミット
  • 診断ログや環境変数ダンプ
  • 親プロセスから子プロセスへの不要な継承
  • 同一ユーザー権限で動く別プロセス
  • バックアップや同期対象への混入

そこで、リポジトリにはsecretの名前と取得経路だけを保存し、値はSecret Managerから必要なプロセスへだけ渡す。

Password ManagerとSecrets Managerの役割を分ける

Bitwardenには、今回の構成で役割の異なる2つの保管先が登場する。

保管先 保存するもの 用途
Password Manager 端末専用machine access token Secrets Managerへ接続するためのbootstrap
Secrets Manager QIITA_TOKENなどのAPI secret 投稿プログラムへ実行時注入

Secrets Manager CLIはmachine account用access tokenで認証する。machine accountには、対象プロジェクトのread-only権限だけを与える。

access tokenは端末ごとに分ける。PCを紛失した場合でも、その端末のtokenだけを失効できる。

DPAPI blobを初回作成する

マスターパスワードをPowerShellのコマンドライン引数へ直接書いてはいけない。Read-Host -AsSecureStringで受け取り、現在のWindowsユーザーに紐づくDPAPI暗号文として保存する。

$secure = Read-Host 'Bitwarden master password' -AsSecureString
$secure | ConvertFrom-SecureString |
  Set-Content -NoNewline -Encoding ASCII '.\data\bitwarden_unlock.dpapi'

このファイルは必ず.gitignoreへ追加する。

data/bitwarden_unlock.dpapi

ConvertFrom-SecureStringを追加キーなしで使った場合、暗号文は基本的に作成時と同じWindowsユーザー・同じ端末でのみ復号できる。別端末へコピーしても自動解除用credentialとしては使えない。

DPAPIから非対話でBitwardenを解除する

以下は処理の要点だけを抜き出した例である。

$secure = Get-Content -Raw '.\data\bitwarden_unlock.dpapi' |
  ConvertTo-SecureString

$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try {
    $password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
}
finally {
    [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}

try {
    $env:BW_PASSWORD = $password
    $env:BW_SESSION = bw unlock --passwordenv BW_PASSWORD --raw
}
finally {
    Remove-Item Env:BW_PASSWORD -ErrorAction SilentlyContinue
    $password = $null
}

ポイントは次の3つ。

  • パスワードをコマンド引数に含めない
  • BW_PASSWORDは現在のプロセスとbw子プロセスでのみ使用する
  • BSTRはZeroFreeBSTRで解放する

PowerShellの文字列は完全なsecure memoryではないため、この方式でも同一ユーザー権限のマルウェアまでは防げない。しかし、平文ファイルや永続環境変数より露出範囲を狭められる。

BWS bootstrap itemを登録する

Secrets Managerで次を用意する。

  1. 投稿用プロジェクトを作成する
  2. QIITA_TOKENをSecretとして登録する
  3. 投稿専用machine accountへ対象プロジェクトのread-only権限を付ける
  4. 端末専用access tokenを発行する

access tokenは発行画面を閉じると再表示できないため、その場でPassword Managerへ保存する。

Password Managerには「ログイン」項目として登録する。

フィールド
項目名 <BWS_BOOTSTRAP_ITEM>
ユーザー名 <MACHINE_ACCOUNT_NAME>
パスワード 発行したmachine access token
URI 空欄

投稿スクリプトは項目名だけを設定として持ち、値を次のように取得する。

$bootstrapToken = bw get password '<BWS_BOOTSTRAP_ITEM>'
$env:BWS_ACCESS_TOKEN = $bootstrapToken

項目作成後はPassword Manager CLIを同期する。Web Vaultへ追加しても、ローカルCLIのキャッシュが古いままだとNot found.になる場合がある。

bw sync

Secrets Managerから投稿プロセスへ注入する

Secrets Manager CLIのrunを使うと、対象プロジェクトのsecretを子プロセスへ注入できる。

$payload | bws run `
  --no-inherit-env `
  --project-id '<PROJECT_ID>' `
  -- python qiita_post.py `
     --content - `
     --private false `
     --confirm-live

--no-inherit-envを使い、親プロセスの不要な環境変数を渡さない。実際の運用では、実行可能ファイルとスクリプトを絶対パスで指定すると、PATHを継承しない状態でも起動しやすい。

最後に、セッション内の秘密値を削除する。

finally {
    Remove-Item Env:QIITA_TOKEN -ErrorAction SilentlyContinue
    Remove-Item Env:BWS_ACCESS_TOKEN -ErrorAction SilentlyContinue
    Remove-Item Env:BW_SESSION -ErrorAction SilentlyContinue
    Remove-Item Env:BW_PASSWORD -ErrorAction SilentlyContinue
}

実際に遭遇した失敗

node.exe : Not found.

PowerShellからnpm生成のbw.ps1を呼んだ際、Node.jsがインストール済みでもラッパー内の解決に失敗した。

この環境ではbw.cmdを明示的に解決することで回避した。

$bwCmd = (Get-Command bw.cmd -ErrorAction Stop).Source
& $bwCmd status

Not found.

DPAPIによるPassword Manager解除は成功していたが、期待するbootstrap itemが保管庫に存在しなかった。

これは自動解除の失敗ではない。

DPAPI解除             成功
Password Manager同期  成功
bootstrap item取得    失敗
Secrets Manager接続   未実行
Qiita API呼び出し     未実行

エラーを「Bitwardenが解除できない」とまとめず、どの境界で止まったかを記録すると原因を誤認しにくい。

投稿前にdry-runを分離する

認証確認と本文確認は別コマンドにする。

# secretを取得せず、投稿payloadだけ確認
.\Publish-QiitaDraft.ps1 `
  -ArticlePath '.\article.md' `
  -ResultPath "$env:TEMP\qiita-dry-run.json" `
  -DryRun

# DPAPIからBWS接続までを確認し、Qiita APIは呼ばない
.\Publish-QiitaDraft.ps1 `
  -ArticlePath '.\article.md' `
  -ResultPath "$env:TEMP\qiita-auth-check.json" `
  -ValidateAuthenticationOnly

本番投稿には別途-ConfirmLiveを要求する。dry-run成功を投稿承認として扱わない。

検証結果

今回確認できた範囲は次のとおり。

検証項目 結果
PowerShell構文解析 エラー0
DPAPI blobの復号 成功
Password Managerの非対話解除 成功
Qiita payloadのdry-run 成功
front matterからのタイトル・5タグ取得 成功
BWS bootstrap item取得 未登録のため失敗
Secrets Manager接続 未実行
Qiita API投稿 未実行

したがって、この記事は「DPAPI自動解除と投稿スクリプトの実装・dry-run」までは検証済みだが、Secrets Managerを経由した本番投稿の成功例ではない。

セキュリティ上の注意

  • DPAPI blob、machine access token、QIITA_TOKENをGitへ入れない
  • machine accountは用途別・端末別に作る
  • 権限はread-onlyを基本にする
  • access tokenには可能なら有効期限を設定する
  • tokenをチャット、スクリーンショット、コマンド引数へ貼らない
  • 投稿プロセスから未知のスクリプトやshellを起動しない
  • 漏えいが疑われたらmachine access tokenを失効し、Qiita tokenもrotateする
  • 外部投稿の承認とsecret取得権限は別のゲートとして扱う

参考資料

確認日:2026年7月22日

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?