1Passwordから情報を取得するgit credential helperを作ってみた

2019.12.28

DA事業部の岩澤です。

最近、色々ありまして社用PCにWSLを導入しました。
(MacにVMWare使ってWindows10入れてその上にWSLのUbuntu入れました。さらにMac側にはDocker環境もインストールしているので、時折PC FANがいい音を奏でます)

ついでにgit操作も行えるようにと git(ver.2.24.0)をインストールしたりしました。

すると、設定が悪いのかリモートリポジトリにアクセスする度にパスワード入力を求められてしまい...
・pushしようとしてパスワード聞かれてコピー&ペースト
・fetchしようとしてパスワード聞かれてコピー&ペースト
・cloneしようとしてパスワード聞かれてコピー&ペースト

...いけない、このままでは虚ろな目で作業することになり精神的にもよろしくないです。

これを改善すべく1Passwordからユーザーとパスワードを引っ張ってくるgit credential helperを自作することにしました。

※ブログ作成中、「既存のヘルパ使えばよかったのでは?」とか「前使っていたgit for windowsの認証ヘルパを使えば?」とか気が付きましたが、このまま突っ走ります。

実装環境

実行環境は以下の通りです。

Mac OS:macOS Mojave 10.14.6
VMWare:11.1.1
Windows 10 Pro:version 1809
WSL Ubuntu:1804.2019.521.0
fish:3.0.2 git:2.24.0
python:3.7.3
op (1PasswordのCUI):0.8.0

git credential helperについて

簡単に言えば「引数でアクションを指定して、標準入力で追加情報を得て、必要ならば標準出力を返す」というものです。

アクションは以下の3つがあります。

  • 認証情報の登録:store
  • 認証情報の削除:erase
  • 認証情報の取得:get

今回は1Passwordから情報を取得するという方針なので、get(取得)アクションのみを実装します。

getアクションについて

getアクションの仕様ですが、ざっくり言えば『標準入力から特定の書式でプロトコルとホスト名を渡されるので、知っていればユーザ名とパスワードを返す。知らなければ何も返さない』というものです。

標準入力で渡されるのは以下の書式となります。

protocol=(プロトコル名)
host=(ホスト名)
(改行のみ)

例えばhttps://github.com/ ならば

protocol=https
host=github.com
(改行のみ)

が渡されます。

標準入力から渡されたプロトコル&ホスト名の認証情報を知っている場合は標準出力で以下を返します。

protocol=(プロトコル名)
host=(ホスト名)
username=(ユーザ名)
password=(パスワード)

先ほどの例:https://github.comに対しての出力は以下のようになります。

protocol=https
host=github.com
username=cm-nijigen-shain
password=mesoko-kawaii

1Password CUIからの取得

1Passwordに登録したアイテムの情報は以下のコマンドを実行するとjson形式で取得できます。

$ op get item <アイテム名>  

ソース

以下が実装です。

git-credential-read-only

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys
from git_from_onepassword import get_onepassword_item_list


ITEM_TARGET = ["GitHub_test"]  # 1Passwordのアイテム名に変更する


import subprocess
from subprocess import PIPE
import json


def run_onepassword(item_name):
    cmd = ["op", "get", "item", item_name]
    proc = subprocess.run(cmd, stdout=PIPE, text=True)
    return str.join("", proc.stdout.strip().split("\n"))


def get_onepass_info(item_name):
    # アイテムの情報をjson形式で取得
    sjq = run_onepassword(item_name)
    try:
        # jsonのparserを通す
        jq = json.loads(sjq)
    except json.JSONDecodeError:
        print("「op signin <signinaddress>」を実行してください")
        exit(0)

    # ウェブサイトを取得
    url = jq["overview"].get("url")
    if url is None:
        return None, None, None, None

    protocol = url.split(":")[0]
    host = url[url.find("//")+2:]

    # ユーザ名とパスワードを取得
    fields = jq["details"]["fields"]
    username = next(
        filter(lambda x: x.get("designation") == "username", fields),
        {},
        ).get("value")
    password = next(
        filter(lambda x: x.get("designation") == "password", fields),
        {},
        ).get("value")

    return protocol, host, username, password


def get_onepassword_item_list(item_name_list):
    itemlist = {}
    for item_name in item_name_list:
        prtcl, hst, usr, pwd = get_onepass_info(item_name)
        itemlist[prtcl] = {hst: [usr, pwd]}

    return itemlist


def main():
    if len(sys.argv) <= 1:
        exit(0)
    
    # get以外のアクションは何もせずに正常終了
    if str.lower(sys.argv[1]) != "get":
        print(sys.argv[1])
        exit(0)

    # 標準入力から取得
    input_list = list()
    try:
        while True:
            input_s = str.strip(input())

            if input_s == "":
                break
            input_list.append(input_s)
    except EOFError:
        pass

    known = dict()
    for s in input_list:
        k, v = s.split("=")
        known[k] = v

    # 1Passwordから指定したアイテムの情報取得
    itemlist = get_onepassword_item_list(ITEM_TARGET)

    # プロトコルとホストが一致する情報があるか確認
    hostdict = itemlist.get(known.get("protocol"))
    if hostdict is None:
        exit(0)

    userpass = hostdict.get(known.get("host"))
    if userpass is None:
        exit(0)

    username, password = userpass

    # 認証情報を標準出力へ
    print("protocol={}".format(known.get("protocol")))
    print("host={}".format(known.get("host")))
    print("username={}".format(username))
    print("password={}".format(password))


if __name__ == "__main__":
    main()
  • 標準入力input()で取得、EOFErrorがあるのでtry-exceptで対応
  • 1Password CUIの呼び出しはsubprocess.run
  • 1Passwordのjson書式
  • デバッグ用のprint文を残していると動かない

あたりがハマりポイントなので注意です。

環境準備

認証ヘルパはできました。あとはこれを使うだけです。

1) 1Passwordの対象アイテムの設定

参照するアイテムのウェブサイト欄を設定します。

2) 1PasswordのCUIをDLしてきてパスの通る場所に配置する

1PasswordのCUIをダウンロードし、Pathの通るところへ配置します。

※私は~/dl_tools/1password/にコピーし、 /usr/local/binにリンクを作成しています。

$cd /usr/local/bin
$sudo ln -nfs ~/dl_tools/1password/op

3) 認証ヘルパもPathの通るところへ配置

※ファイル本体は~/tools_scripts/git-credential-read-onlyに配置

$cd /usr/local/bin
$sudo ln -nfs ~/tools_scripts/git-credential-read-only

4) 認証ヘルパとして設定

以下のコマンドを実行して認証ヘルパとして設定します。

$ git config --global credential.helper read-only

実際に使ってみる

1) 1Passwordのサインイン

gitコマンドを叩く前に1Password関連の環境変数を設定します。

★ fishの場合

$ eval (op signin <subdomain>)

→マスタパスワードの入力を求められるので入力します。

★ bash系の場合

$ eval $(op signin <subdomain>)

2) gitコマンドの実行

お疲れさまでした。長かった準備が完了しました。後は好きなコマンドを実行します。

$ git push -u origin HEAD

まとめ

途中から目的と手段が入れ替わってしまった気がしますが、個人的にはいい感じにできたかなー?と思います。
誰かの役に立てれば幸いです。

それでは、良いお年を!

参考文献

Git - 認証情報の保存