pythonからssh-agentを使う。paramikoを拡張して使う。

# 動機

`ssh-agent`は、`ssh`のリモート接続の際の認証以外でも、内緒にしたいデータの鍵のキャッシュとしても広く使い道があるのではないか、と思い立って、`python`から`ssh-agent`にアクセスするユーティリティをつくっておこうと思った。

# 概要・実装したい機能

- `Pyton`で、`ssh-agent`へのアクセスも含めたモジュールとして、[`paramiko`](https://www.paramiko.org/)というのを見つけたので、これを利用する。
- 暗号関係のライブラリとしては この`paramiko`が依存している[`cryptography`](https://cryptography.io/en/latest/)を利用する。
- 通常`ssh-agent`を使うには、初回には`ssh-keygen`で秘密鍵と公開鍵をつくって、次に`ssh-agent`を走らせて、シェルで環境変数`SSH_AGENT_SOCK`を設定して、 `ssh-add`でパスフレーズを叩いて鍵を`ssh-agent`に登録して...という準備が必要である。こういうのは自動化したい。
    1. `ssh-agent`に使おうとする名前の鍵セットが登録済みであれば使う
    2. 使おうとする名前の秘密鍵と公開鍵のセットがなければつくる。すでにあれば、既存のものを利用する。
    3. 2.で準備した鍵を`ssh-agent`に登録する。

# 必要なこと

`paramiko`で、`ssh-agent`とのやりとりをするのは、`paramiko.agent.AgentKey`というクラスである。が、2024年1月現在(バージョン3.4.0)では[API Doc](https://docs.paramiko.org/en/latest/api/agent.html)や[ソース](https://github.com/paramiko/paramiko/blob/main/paramiko/agent.py)を見る限り、クラスのコンストラクタで`ssh-agent`から登録されている鍵情報とその利用を提供するクラス`paramiko.agent.AgentKey`のリスト作って保持する機能があるという実にシンプルなものである。また、`paramiko`では、暗号化した鍵を保持するために[`paramiko.pkey.PKey`](https://docs.paramiko.org/en/latest/api/keys.html)という基底クラスにして、暗号方法ごとの("RSA","DSA", "ECDSA", "ED25519")[継承のクラス](https://docs.paramiko.org/en/latest/api/keys.html)を実装しているが、いずれも`openssh`形式のファイルを読み込んで構築するようになっている。

そのため、前述の機能を実現するには、下記の機能を追加する必要がある。

- `ssh-add`のように、鍵を`ssh-agent`に登録する機能。
- 追加した鍵を利用するために、保持した鍵のリストを更新する機能。
- `ssh-keygen`に相当する、鍵を新規作成して`paramiko`の鍵クラスとして読み込む機能。

このうち最初の2点については、あえて継承して新しいクラスをつくるまでもないと思うので、[ここ](https://www.lifewithpython.com/2014/08/python-add-instance-methods-to-existing-class.html)などで紹介されているような方法で、`paramiko.agent.AgentKey`にメンバ関数を追加してみる。3点目にかんしては、その他の機能も含めて、新しいクラス(`SSHAgentManager`)を作ってみることにする。

# `paramiko.agent.Agent`に、鍵を`ssh-agent`に登録する機能を追加する

githubに[関係しそうなスレッド](https://github.com/paramiko/paramiko/issues/778)があったのを見つけたのでこれを参考にしてみた。このスレッドでは、"RSA"のみが例になっていたので、他の暗号方法についても`openssh`にある[`ssh-agent`プロトコルのドキュメント](https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent-04#section-4.2)に従って実装してみる。鍵の種類ごとにプロトコルに従って鍵データを表現するメッセージ(`paramiko.message.Message`クラス)を作成して`ssh-agent`に送る、ということらしい。

```python3:paramiko.agent.AgentKey.ssh_add_key() を追加する
paramiko.agent.SSH_AGENT_FAILURE = 5
paramiko.agent.SSH_AGENT_SUCCESS = 6
paramiko.agent.cSSH2_AGENTC_ADD_IDENTITY = paramiko.common.byte_chr(17)

def paramiko_agent_agent_ssh_add_key(self, key : paramiko.pkey.PKey, key_comment: str=""):
    """
    Register the private key to ssh-agent
    """
    ptype, result = (None, None)
    if isinstance(key, paramiko.rsakey.RSAKey):
        msg = paramiko.message.Message()
        msg.add_byte(paramiko.agent.cSSH2_AGENTC_ADD_IDENTITY)
        msg.add_string(key.get_name())
        msg.add_mpint(key.public_numbers.n)
        msg.add_mpint(key.public_numbers.e)
        msg.add_mpint(key.key.private_numbers().d)
        msg.add_mpint(key.key.private_numbers().iqmp)
        msg.add_mpint(key.key.private_numbers().p)
        msg.add_mpint(key.key.private_numbers().q)
        msg.add_string(key_comment)
        ptype, result = self._send_message(msg)
    elif isinstance(key, paramiko.ecdsakey.ECDSAKey):
        msg = paramiko.message.Message()
        msg.add_byte(paramiko.agent.cSSH2_AGENTC_ADD_IDENTITY)
        msg.add_string(key.ecdsa_curve.key_format_identifier)
        msg.add_string(key.ecdsa_curve.nist_name)
        msg.add_string(key.verifying_key.public_bytes(encoding=cryptography.hazmat.primitives.serialization.Encoding.X962,
                                                      format=cryptography.hazmat.primitives.serialization.PublicFormat.UncompressedPoint))
        msg.add_mpint(key.signing_key.private_numbers().private_value)
        msg.add_string(key_comment)
        ptype, result = self._send_message(msg)
    elif isinstance(key, paramiko.ed25519key.Ed25519Key):
        msg = paramiko.message.Message()
        msg.add_byte(paramiko.agent.cSSH2_AGENTC_ADD_IDENTITY)
        msg.add_string(key.get_name())
        msg.add_string(key._signing_key.verify_key._key)
        msg.add_string(key._signing_key._seed+key._signing_key.verify_key._key)
        msg.add_string(key_comment)
        ptype, result = self._send_message(msg)
    elif isinstance(key, paramiko.dsskey.DSSKey):
        msg = paramiko.message.Message()
        msg.add_byte(paramiko.agent.cSSH2_AGENTC_ADD_IDENTITY)
        msg.add_string(key.get_name())
        msg.add_mpint(key.p)
        msg.add_mpint(key.q)
        msg.add_mpint(key.g)
        msg.add_mpint(key.y)
        msg.add_mpint(key.x)
        msg.add_string(key_comment)
        ptype, result = self._send_message(msg)
    else:
        raise NotImplementedError("Unknown key type")
    return (ptype, result)
setattr(paramiko.agent.Agent, 'ssh_add_key', paramiko_agent_agent_ssh_add_key)
```

# `paramiko.agent.Agent`で、`ssh-agent`から鍵のリストを読み込み直す機能を追加する。

これに関しては、コンストラクタ(`__init__(self,...)`)の該当する部分のみの機能のメソッドをつくればよい。

```python3:paramiko.agent.AgentKey.fetch_agent_keylist() を追加する
def paramiko_agent_agent_fetch_agent_keylist(self, verbose=False):
    """
    Fetch the list of the registered keys from ssh-agent
    """
    ptype, result = self._send_message(paramiko.agent.cSSH2_AGENTC_REQUEST_IDENTITIES)
    if ptype != paramiko.agent.SSH2_AGENT_IDENTITIES_ANSWER:
        raise SSHException("could not get keys from ssh-agent")
    keys = []
    for i in range(result.get_int()):
        keys.append(paramiko.agent.AgentKey(agent=self,
                                            blob=result.get_binary(),
                                            comment=result.get_text()))
    self._keys = tuple(keys)
setattr(paramiko.agent.Agent, 'fetch_agent_keylist', paramiko_agent_agent_fetch_agent_keylist)
```