LoginSignup
5
2

GitHub 用の公開鍵でパスワードレスの暗号化/復号をしてみる

Last updated at Posted at 2023-05-14

はじめに

  • GitHub や GitLab に認証用に登録している公開鍵は、だれでも取得が可能です。その公開鍵を使って、ファイルを暗号化/復号できるコマンドを GO 言語で作りました。
  • パスワードなしで暗号ファイルを扱えます。もちろん秘密鍵にパスフレーズが登録されている場合には、それは必要ですが、送信者から受信者にパスワードを受け渡す必要はありません。
  • コマンドの名前は git-caesar にしてみました。暗号といえば caesar でしょ? (シーザー暗号は使っていないので安心してください)

とりあえずできたばかりのコマンドなので、バグなどたくさんあるかもしれません。
暗号技術に詳しいわけでもないので、詳しい人のご指摘などお待ちしております。

また、本コマンドは QiiCipher (参考:Qiita の記事)に多大な影響を受けています。
QiiCipher も、GitHub の公開鍵 (サポートしているのは RSA のみ) を使って、小さいファイルを暗号化/復号するというシェルスクリプトベースのツールです。
QiiCipher は標準のシェル機能 + OpenSSL/OpenSSH という最小限の条件で実現するという方向性に対して、本コマンドは GO 言語で実装してより容易に扱えるようにする、という方向性で作ったものです。

ちなみに、公開鍵暗号についての詳しい説明などは割愛します。すでに秀逸な記事がたくさんあると思うので、そちらをご覧ください(例えば 2つの公開鍵暗号(公開鍵暗号の基礎知識) など)

GitHub や GitLab の認証用公開鍵

GitHub や GitLab には、git push 等の際の認証用に ssh 用の公開鍵を登録しておくことが一般的だと思います。
この登録した公開鍵は、誰でも取得可能です。

  • GitHub の場合: https://github.com/USER_NAME.keys
  • GitLab の場合: https://gitlab.com/USER_NAME.keys

これらの公開鍵は基本的に署名用のものですが、一部のアルゴリズムでは直接、あるいは間接的に暗号用に使用することも可能なものがあります。
この公開鍵を使って、パスワードレスでの暗号化/復号を実現しています。
本コマンドでは、公開鍵アルゴリズムとして、RSA (鍵長 1024 bit 以上)、ECDSA、ED25519 をサポートしています。

  • RSA (鍵長 1024 bit 以上)
    • 公開鍵のプリフィックス ssh-rsa
  • ECDSA
    • P256 -- 公開鍵のプリフィックス ecdsa-sha2-nistp256
    • P384 -- 公開鍵のプリフィックス ecdsa-sha2-nistp384
    • P521 -- 公開鍵のプリフィックス ecdsa-sha2-nistp521
  • ED25519 -- 公開鍵のプリフィックス ssh-ed25519

それ以外の GitHub/GitLab で対応している公開鍵アルゴリズム、つまり DSA、シークレットキー用の ECDSA-SK と ED25519-SK および、鍵長が 1024 bit 未満の RSA はサポートしていません。

  • DSA -- GitHub/GitLab では非推奨のため。また実現方法の調査不足のため。
    • 公開鍵のプリフィックス ssh-dss (※ ssh-dsa ではないんですね)
  • ECDSA-SK, ED25519-SK -- 調べた範囲では実現ができないと判断。すくなくとも単純にライブラリを使うだけでは実現できない。
    • 公開鍵のプリフィックス sk-ecdsa-sha2-nistp256@openssh.com
    • 公開鍵のプリフィックス sk-ssh-ed25519@openssh.com
  • RSA (鍵長 1024 bit 未満) -- セキュリティ的に問題があると判断。また、鍵長が短いため暗号可能なビット長に制限がある。

メッセージ本文の暗号化

公開鍵暗号方式を使う場合でも、メッセージ本体は対称鍵暗号方式で暗号化するのが一般的です。
本コマンドでは、対称鍵暗号方式の AES を AES-256-CBC モードで使用しています。

aes.go
package aes

import (
	"bytes"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
)

func Encrypt(key, plaintext []byte) ([]byte, error) {
	// pad the message with PKCS#7
	padding := aes.BlockSize - len(plaintext)%aes.BlockSize
	padtext := append(plaintext, bytes.Repeat([]byte{byte(padding)}, padding)...)

	ciphertext := make([]byte, aes.BlockSize+len(padtext))
	iv := ciphertext[:aes.BlockSize]
	encMsg := ciphertext[aes.BlockSize:]

	// generate initialization vector (IV)
	_, err := rand.Read(iv)
	if err != nil {
		return nil, err
	}

	// encrypt message (AES-CBC)
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	cbc := cipher.NewCBCEncrypter(block, iv)
	cbc.CryptBlocks(encMsg, padtext)

	return ciphertext, nil
}

func Decrypt(key, ciphertext []byte) ([]byte, error) {
	// extract the initial vector (IV)
	iv := ciphertext[:aes.BlockSize]
	encMsg := ciphertext[aes.BlockSize:]

	// create an decrypter in CBC mode
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	cbc := cipher.NewCBCDecrypter(block, iv)

	// decrypt ciphertext
	msgLen := len(encMsg)
	decMsg := make([]byte, msgLen)
	cbc.CryptBlocks(decMsg, encMsg)

	// Unpad the message with PKCS#7
	plaintext := decMsg[:msgLen-int(decMsg[msgLen-1])]
	return plaintext, nil
}

最初に何らかの方法、例えば乱数や鍵交換などで共有鍵(32 byte = 256 bit)を用意します。
その共有鍵を使って AES-256-CBC で暗号化します。
暗号化の際には初期ベクトル(initialization vector; IV)という乱数が必要になります。
IV は、例えるならパスワードのハッシュ時のソルトのようなもので、同じデータを暗号化しても暗号文が異なる物になるようにするためのものです。これは第三者に見られても問題ないデータです。
この IV は、受信者に受け渡さないといけないので、暗号文の先頭に挿入するようにしています。
受信時には先頭の 1 ブロック分を IV として切り出してから復号することになります。

また、AES-256-CBC ではブロック長にあわせたパディングが必要になります。
パディングにはいくつか方法がありますが、今回は PKCS#7 という方法でパディングをしています。

暗号文はそのまま受け渡せばよいのですが、暗号化に使った共有鍵をどうやって相手と共有するかが重要なポイントになります。

RSA の場合

RSA の公開鍵の場合は署名/検証以外にも、充分に小さいデータに限りますが暗号化/復号にも使用可能です。
ただし、素人がプリミティブな暗号方法を使うのは危険なので(ブロック暗号を ECB 暗号利用モードで使うようなミスを犯しかねない)、RSA を使った暗号スイートである RSA-OAEP を利用しました。

rsa.go
package rsa

import (
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
)

func Encrypt(pubKey *rsa.PublicKey, plaintext []byte) ([]byte, error) {
	return rsa.EncryptOAEP(sha256.New(), rand.Reader, pubKey, plaintext, []byte{})
}

func Decrypt(prvKey *rsa.PrivateKey, ciphertext []byte) ([]byte, error) {
	return rsa.DecryptOAEP(sha256.New(), rand.Reader, prvKey, ciphertext, []byte{})
}

func Sign(prvKey *rsa.PrivateKey, message []byte) ([]byte, error) {
	hash := sha256.Sum256(message)
	return rsa.SignPKCS1v15(rand.Reader, prvKey, crypto.SHA256, hash[:])
}

func Verify(pubKey *rsa.PublicKey, message, sig []byte) bool {
	hash := sha256.Sum256(message)
	err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hash[:], sig)
	return err == nil
}

試したところ RSA の鍵長が 800 bit 程度あれば、32 byte の鍵は暗号化できるようです。
今回サポートしている鍵長は 1024 bit 以上なので、32 byte の鍵は問題なく暗号ができます。

AES-256-CBC 用の共有鍵を受信者の RSA 公開鍵を使って暗号化して相手に送れば、受け取り手は自分の RSA 秘密鍵で復号ができます。

ECDSA の場合

ECDSA は署名用のアルゴリズムです。そのため直接、暗号化/復号用には使えません。
ECDSA の関連したものに ECDH 鍵交換アルゴリズムがあり、また ECDSA の鍵はほぼそのままで ECDH の鍵として使えます。
そして鍵交換を使えば、送信者は「送信者の秘密鍵」と「受信者の公開鍵」を使って、受信者は「受信者の秘密鍵」と「送信者の公開鍵」を使って(つまり、お互いに「自分の秘密鍵」と「相手の公開鍵」を使って)鍵交換をすれば、同一の鍵を得ることができます。

この交換した鍵を使って AES 等の対称鍵暗号方式で暗号化/復号すれば、鍵そのものを受け渡さなくてもよいわけです。

ecdsa.go
package ecdsa

import (
	"crypto/ecdsa"
	"crypto/rand"
	"crypto/sha256"
	"encoding/asn1"
	"math/big"

	"github.com/yoshi389111/git-caesar/caesar/aes"
)

func Encrypt(peersPubKey *ecdsa.PublicKey, message []byte) ([]byte, *ecdsa.PublicKey, error) {
	curve := peersPubKey.Curve

	// generate temporary private key
	tempPrvKey, err := ecdsa.GenerateKey(curve, rand.Reader)
	if err != nil {
		return nil, nil, err
	}

	// key exchange
	exchangedKey, _ := curve.ScalarMult(peersPubKey.X, peersPubKey.Y, tempPrvKey.D.Bytes())
	sharedKey := sha256.Sum256(exchangedKey.Bytes())

	// encrypt AES-256-CBC
	ciphertext, err := aes.Encrypt(sharedKey[:], message)
	if err != nil {
		return nil, nil, err
	}
	return ciphertext, &tempPrvKey.PublicKey, nil
}

func Decrypt(prvKey *ecdsa.PrivateKey, peersPubKey *ecdsa.PublicKey, ciphertext []byte) ([]byte, error) {
	curve := prvKey.Curve

	// key exchange
	exchangedKey, _ := curve.ScalarMult(peersPubKey.X, peersPubKey.Y, prvKey.D.Bytes())
	sharedKey := sha256.Sum256(exchangedKey.Bytes())

	// decrypt AES-256-CBC
	return aes.Decrypt(sharedKey[:], ciphertext)
}

type sigParam struct {
	R, S *big.Int
}

func Sign(prvKey *ecdsa.PrivateKey, message []byte) ([]byte, error) {
	hash := sha256.Sum256(message)
	r, s, err := ecdsa.Sign(rand.Reader, prvKey, hash[:])
	if err != nil {
		return nil, err
	}
	sig, err := asn1.Marshal(sigParam{R: r, S: s})
	if err != nil {
		return nil, err
	}
	return sig, nil
}

func Verify(pubKey *ecdsa.PublicKey, message, sig []byte) bool {
	hash := sha256.Sum256(message)
	signature := &sigParam{}
	_, err := asn1.Unmarshal(sig, signature)
	if err != nil {
		return false
	}
	return ecdsa.Verify(pubKey, hash[:], signature.R, signature.S)
}

※後述しますが、送信者用の鍵ペアは使い捨てで、毎回生成しています。

ED25519 の場合

ED25519 も署名用のアルゴリズムです。そのため同様に暗号化/復号用には使えません。
こちらも ED25519 に関連する X25519 鍵交換アルゴリズムがあります。
ED25519 の鍵をもとに計算をすると、X25519 の鍵を作ることができます。
ただし、不可逆変換のために逆方向の X25519 の鍵から ED25519 の鍵を作ることはできません。

ちなみに、ED25519 は素数 $2^{255} - 19$ におけるツイストエドワーズ曲線を使用しており、X25519 (あるいは Curve25519)は同じ素数 $2^{255} - 19$ におけるモンゴメリ曲線を使用しています。
また、このモンゴメリ曲線とツイストエドワーズ曲線は、双有理同値(相互に有利変換が可能)です。

ED25519 も X25519 も、この素数の $2^{255} - 19$ から名前がとられています。

toX25519.go
package ed25519

import (
	"crypto/ecdh"
	"crypto/ed25519"
	"crypto/sha512"
	"math/big"
)

func toX2519PrivateKey(edPrvKey *ed25519.PrivateKey) (*ecdh.PrivateKey, error) {
	key := sha512.Sum512(edPrvKey.Seed())
	return ecdh.X25519().NewPrivateKey(key[:32])
}

// p = 2^255 - 19
var p, _ = new(big.Int).SetString("7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed", 16)
var one = big.NewInt(1)

func toX25519PublicKey(edPubKey *ed25519.PublicKey) (*ecdh.PublicKey, error) {
	// convert to big-endian
	bigEndianY := toReverse(*edPubKey)

	// turn off the first bit
	bigEndianY[0] &= 0b0111_1111

	y := new(big.Int).SetBytes(bigEndianY)
	numer := new(big.Int).Add(one, y)          // (1 + y)
	denomInv := y.ModInverse(y.Sub(one, y), p) // 1 / (1 - y)
	// u = (1 + y) / (1 - y)
	u := numer.Mod(numer.Mul(numer, denomInv), p)

	// convert to little-endian
	littleEndianU := toReverse(u.Bytes())

	// create x25519 public key
	return ecdh.X25519().NewPublicKey(littleEndianU)
}

func toReverse(input []byte) []byte {
	length := len(input)
	output := make([]byte, length)
	for i, b := range input {
		output[length-i-1] = b
	}
	return output
}

余談ですが、類似のアルゴリズム/曲線に ED448 / Curve448 というものがあり、こちらは $2^{448} - 2^{224} - 1$ という素数を使っているものです。
そのため、ED22519 よりも ED448 の方が強力なアルゴリズムのようです。よく知らないで見ると ED448 の方が弱そうに見えますね。

X25519 を使っての鍵交換は以下のよう行います。

ed25519.go
package ed25519

import (
	"crypto/ecdh"
	"crypto/ed25519"
	"crypto/rand"
	"crypto/sha256"

	"github.com/yoshi389111/git-caesar/caesar/aes"
)

func Encrypt(otherPubKey *ed25519.PublicKey, message []byte) ([]byte, *ed25519.PublicKey, error) {

	// generate temporary key pair
	tempEdPubKey, tempEdPrvKey, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return nil, nil, err
	}

	// convert ed25519 public key to x25519 public key
	xOtherPubKey, err := toX25519PublicKey(otherPubKey)
	if err != nil {
		return nil, nil, err
	}

	// convert ed25519 prevate key to x25519 prevate key
	xPrvKey, err := toX2519PrivateKey(&tempEdPrvKey)
	if err != nil {
		return nil, nil, err
	}

	// key exchange
	sharedKey, err := exchangeKey(xPrvKey, xOtherPubKey)
	if err != nil {
		return nil, nil, err
	}

	// encrypt AES-256-CBC
	ciphertext, err := aes.Encrypt(sharedKey, message)
	if err != nil {
		return nil, nil, err
	}
	return ciphertext, &tempEdPubKey, nil
}

func Decrypt(prvKey *ed25519.PrivateKey, otherPubKey *ed25519.PublicKey, ciphertext []byte) ([]byte, error) {

	// convert ed25519 public key to x25519 public key
	xOtherPubKey, err := toX25519PublicKey(otherPubKey)
	if err != nil {
		return nil, err
	}

	// convert ed25519 prevate key to x25519 prevate key
	xPrvKey, err := toX2519PrivateKey(prvKey)
	if err != nil {
		return nil, err
	}

	// key exchange
	sharedKey, err := exchangeKey(xPrvKey, xOtherPubKey)
	if err != nil {
		return nil, err
	}

	// decrypt AES-256-CBC
	return aes.Decrypt(sharedKey, ciphertext)
}

func exchangeKey(xPrvKey *ecdh.PrivateKey, xPubKey *ecdh.PublicKey) ([]byte, error) {
	exchangedKey, err := xPrvKey.ECDH(xPubKey)
	if err != nil {
		return nil, err
	}
	sharedKey := sha256.Sum256(exchangedKey)
	return sharedKey[:], nil
}

func Sign(prvKey *ed25519.PrivateKey, message []byte) ([]byte, error) {
	hash := sha256.Sum256(message)
	sig := ed25519.Sign(*prvKey, hash[:])
	return sig, nil
}

func Verify(pubKey *ed25519.PublicKey, message, sig []byte) bool {
	hash := sha256.Sum256(message)
	return ed25519.Verify(*pubKey, hash[:], sig)
}

この交換した鍵を使えば、ECDSA と同様に暗号化/復号が実現できます。

複数の公開鍵/相手と異なる種類の鍵

GitHub や GitLab には複数の公開鍵が登録できます。

複数の PC / 環境で作業をしている場合などに、1 つの秘密鍵を使いまわすのは持ち運び/コピーなどで漏洩の可能性が上がります。
それを避けるため、それぞれの環境で鍵ペアを生成して、そのうちの公開鍵を GitHub などに登録することになります。

本コマンドでは、このような場合に暗号文の受け取り手が便利なように、どの秘密鍵でも復号ができるようにしたいと考えました。

また、相手の鍵のアルゴリズムと、自分の鍵のアルゴリズムが異なるケースも普通にあり得ます。

そこで、以下のように暗号化することにしました。
まず、メッセージ本文を暗号化する共有鍵は乱数で生成します。

  • 相手の公開鍵が RSA の場合:
    1. 受信者の RSA 公開鍵を使って共有鍵を暗号化する。
    2. 暗号化された共有鍵を相手に渡す
  • 相手の公開鍵が ECDSA, ED25519 の場合:
    1. 使い捨ての鍵ペアを生成する。
    2. 使い捨ての秘密鍵と受信者の公開鍵で鍵交換を行う。
    3. 交換した鍵で共有鍵を暗号化する。
    4. 暗号化した共有鍵と使い捨ての公開鍵を相手に渡す

これらの、暗号化された鍵と使い捨ての公開鍵などをまとめた入れ物を、エンベロープ(封筒)と呼ぶことにします。

受信者は受け取ったエンベロープのなかで、自分の秘密鍵で復号できるものを選んで共有鍵を復元し、暗号文を復号することになります。

署名確認

本コマンドには暗号化/復号の他に、「暗号化したメッセージ本文」を送信者の秘密鍵で署名しておき、受信者は GitHub 等の公開鍵を使って署名の検証することで、なりすましや改ざんのチェックをすることが可能です。

GitHub 等をつかった署名検証はオプショナルです。署名検証なしで復号だけも可能です。

暗号文ファイルの構造

本コマンドで生成した暗号文は、以下の 2 ファイルを格納した ZIP ファイルです。

  • caesar.json - 以下の項目を格納した json ファイル
    • 署名データ
    • 署名者の公開鍵
    • バージョン情報
    • エンベロープのリスト
  • caesar.cipher - 暗号化したメッセージ本文

ただし、以下の点に注意してください。

  • 暗号前のファイル名情報は保持していません。必要であれば別途連絡をするなどしてください。
  • 送信者の公開鍵がどこにあるかの情報(GitHub アカウント名や、URL など)は保持していません。
  • 暗号化するファイルは 1 つのみです。複数ファイルをまとめて暗号化した場合には、事前にアーカイブをしてください。

インストール方法

現時点では GO 1.20 以上の環境が必要です。

インストール、およびアップグレードするには、以下のコマンドを実行してください。

go install github.com/yoshi389111/git-caesar@latest

アンインストールするには、以下のコマンドを実行してください。

go clean -i github.com/yoshi389111/git-caesar

使い方

Usage:

  git-caesar [options]

Application Options:

  -h, --help                    print help and exit.
  -v, --version                 print version and exit.
  -u, --public=<target>         github account, url or file.
  -k, --private=<id_file>       ssh private key file.
  -i, --input=<input_file>      the path of the file to read. default: stdin
  -o, --output=<output_file>    the path of the file to write. default: stdout
  -d, --decrypt                 decryption mode.

簡単に補足します。

  • -u 相手の公開鍵のありかを指定します。GitHub ユーザー名っぽければ https://github.com/USER_NAME.keys から取得します。http: あるいは https: から始まれば、web から取得します。そうでなければファイルパスだと判断します。GitHub ユーザー名っぽいファイルを指定したいときには、パス付で指定してください(例: -u ./octacat)。暗号化の際には必須です。復号の場合は、指定されれば署名検証を行います。
  • -k 自分の秘密鍵を指定します。指定しない場合には、 ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519, ~/.ssh/id_rsa の順に探して最初に見つかったものを使用します。
  • -i 入力ファイルです。暗号化の際には暗号対象の平文ファイル。復号の際には復号対象の暗号文ファイルを指定してください。オプション指定がない場合には標準入力から読み込みます。
  • -o 出力ファイルです。オプション指定がない場合には標準出力に出力します。
  • -d 指定すると復号モードになります。指定しなければ暗号化モードです。

使用例

GitHub ユーザー octacat のファイル secret.txt を暗号化し、sceret.zip として保存します。

git-caesar -u octacat -i secret.txt -o secret.zip

同じ状況で、秘密鍵は ~/.ssh/id_secret を使用します。

git-caesar -u octacat -i secret.txt -o secret.zip -k ~/.ssh/id_secret

GitLab ユーザー tanuki のファイル secret.zip を復号し、sceret.txt として保存します。

git-caesar -d -u https://gitlab.com/tanuki.keys -i secret.zip -o secret.txt

同じ状況ですが、署名の検証はしません。

git-caesar -d -i secret.zip -o secret.txt

ソースのありか

以下の GitHub リポジトリにあります。

5
2
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
5
2