Public keys registered for authentication on GitHub and GitLab can be obtained by anyone. I made a command that can encrypt/decrypt files using that public key and own private key.
This command can handle encrypted files without a password. Of course it is necessary if a passphrase is registered with the private key. However, there is no need to pass the password from the sender to the recipient.
I named the command git-caesar
. Don't worry, I don't use the Caesar cipher.
It's a new command, so there may be bugs.
I'm not familiar with encryption technology, so I'm looking forward to hearing from people who know more.
This command is heavily influenced by QiiCipher.
QiiCipher is also a shell script-based tool that encrypts/decrypts small files using RSA public keys for GitHub.
QiiCipher is intended to be implemented with the minimum requirements of standard shell functions + OpenSSL/OpenSSH, while this command is implemented in the GO language to make it easier to handle.
Public key for GitHub and GitLab authentication
I think it's common to register a public key for ssh in GitHub or GitLab for authentication during git push
.
Anyone can obtain this registered public key.
- GitHub public key URL:
https://github.com/USER_NAME.keys
- GitLab public key URL:
https://gitlab.com/USER_NAME.keys
These public keys are primarily for signing, but some algorithms can also be used for encryption.
Using this public key, passwordless encryption/decryption is realized.
Public key encryption algorithms support RSA (key length of 1024 bits or more), ECDSA, and ED25519.
- RSA (key length of 1024 bits or more)
- public key prefix:
ssh-rsa
- public key prefix:
- ECDSA
- P256 -- public key prefix:
ecdsa-sha2-nistp256
- P384 -- public key prefix:
ecdsa-sha2-nistp384
- P521 -- public key prefix:
ecdsa-sha2-nistp521
- P256 -- public key prefix:
- ED25519 -- public key prefix:
ssh-ed25519
Others, namely DSA, ECDSA-SK and ED25519-SK for secret keys, and RSA with key length less than 1024 bits are not supported.
- DSA -- Excluded from GitHub/GitLab due to deprecation and lack of research on how to implement it.
- public key prefix:
ssh-dss
- public key prefix:
- ECDSA-SK, ED25519-SK -- As far as I have researched, I have determined that this is not possible. At least, it cannot be achieved simply by using a library.
- public key prefix:
sk-ecdsa-sha2-nistp256@openssh.com
- public key prefix:
sk-ssh-ed25519@openssh.com
- public key prefix:
- RSA (key length less than 1024 bits) -- determined to be a security problem. Also, since the key length is short, there are restrictions on key encryption.
Message body encryption
Even when public-key cryptography is used, it is common to encrypt the body of the message with symmetric-key cryptography.
This command uses symmetric key encryption method AES in AES-256-CBC mode.
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
}
First, prepare a shared key (32 bytes = 256 bits) by some method, such as random numbers or key exchange.
Encrypt with AES-256-CBC using that shared key.
A random number called an initialization vector (IV) is required for encryption.
An IV is like a salt when hashing a password, so that even if the same data is encrypted, the ciphertext will be different. This is fine to publish.
This IV must be passed to the recipient, so I insert it at the beginning of the ciphertext.
When receiving, the first block is extracted as IV and then decoded.
Also, AES-256-CBC requires padding that matches the block length.
There are several methods of padding, but this time we use the PKCS#7 method.
The ciphertext can be passed as it is, but the important point is how to share the shared key used for encryption with the other party.
For RSA public keys
RSA public keys are limited to small enough data, but can also be used for encryption.
However, primitive encryption methods are difficult for unskilled humans to master (for example, it is easy to make mistakes like using block ciphers in ECB cipher mode). So I used RSA-OAEP, a cipher suite using RSA.
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
}
As a result of testing, if the RSA key length is about 800 bits, it seems that a 32-byte key can be encrypted.
Since the key length supported this time is 1024 bits or more, a 32-byte key can be encrypted without problems.
The shared key for AES-256-CBC is encrypted using the recipient's RSA public key and sent to the other party, who can then decrypt it with their own RSA private key.
For ECDSA public keys
ECDSA is an algorithm for signing. Therefore, it cannot be used directly for encryption/decryption.
Related to ECDSA is the ECDH key exchange algorithm, and ECDSA keys can be used almost as is for ECDH.
And if you use key exchange, the sender uses "sender's private key" and "recipient's public key", and the receiver uses "recipient's private key" and "sender's public key" You can get the same key by exchanging the key.
When using this exchanged key to encrypt/decrypt with a symmetric key encryption system such as AES, there is no need to pass the key itself.
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)
}
Note: As will be described later, the key pair for the sender is generated each time.
For ED25519 public keys
ED25519 is also an algorithm for signing. Therefore, it cannot be used for encryption/decryption as well.
Also related to ED25519 is the X25519 key exchange algorithm.
Calculations based on the ED25519 key yield the X25519 key.
However, it is not possible to make an ED25519 key from a reversed X25519 key due to lossy transformation.
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
}
Key exchange using X25519 is performed as follows.
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)
}
Using this exchanged key, encryption/decryption can be achieved in the same way as ECDSA.
Multiple public keys, different types of keys
GitHub and GitLab can have multiple public keys.
When working on multiple PCs/environments, using a single private key increases the possibility of leakage due to portability/copying.
In order to avoid that, we will generate a key pair in each environment and register the public key of it on GitHub, etc.
In this command, I wanted to make it possible for the receiver of the ciphertext to be able to decrypt it with any private key in such a case.
Also, it is normal for the other party's key algorithm to be different from your own key algorithm.
So I decided to encrypt it like this.
First, the shared key that encrypts the message body is generated with random numbers.
- For RSA:
- Encrypt the shared key using the recipient's RSA public key.
- Pass the encrypted shared key to the other party
- For ECDSA, ED25519:
- Generate a one-time key pair.
- Perform key exchange with the one-time private key and the recipient's public key.
- Encrypt the shared key with the exchanged key.
- Pass the encrypted shared key and the one-time public key to the other party
The container containing the encrypted shared key and the one-time public key is called an envelope.
The recipient selects the envelope that can be decrypted with own private key, restores the shared key, and decrypts the ciphertext.
Signature verification
In addition to encryption/decryption, this command also signs the "encrypted message body" with the sender's private key.
The recipient can check for spoofing and tampering by verifying the signature using her public key such as GitHub.
Signature verification using GitHub etc. is optional. Decryption only is also possible without signature verification.
Ciphertext file structure
The ciphertext generated by this command is a ZIP file containing the following two files.
-
caesar.json
- a json file containing the following items- signature data
- signer's public key
- version information
- list of envelopes
-
caesar.cipher
- encrypted message body
Please note the following:
- This ZIP file does not retain file name information before encryption. If necessary, the sender should inform the recipient of the file name separately.
- This ZIP file does not hold information about where the sender's public key is located (GitHub account name, URL, etc.).
- Encrypt only one file. If you encrypt multiple files at once, archive them in advance.
Installation
Requires go 1.20 or higher.
Execute the following commands to install and upgrade.
go install github.com/yoshi389111/git-caesar@latest
To uninstall, run the following command.
go clean -i github.com/yoshi389111/git-caesar
Usage
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
specifies the location of the peer's public key. Get fromhttps://github.com/USER_NAME.keys
if the one specified looks like a GitHub username. If it starts withhttp:
orhttps:
, it will be fetched from the web. Otherwise, it will be determined as a file path. If you specify a file that looks like GitHub username, specify it with a path (e.g.-u ./octacat
). Required for encryption. For decryption, perform signature verification if specified. -
-k
Specify your private key. If not specified, it searches~/.ssh/id_ecdsa
,~/.ssh/id_ed25519
,~/.ssh/id_rsa
in order and uses the first one found. -
-i
Input file. Plaintext file to be encrypted when encrypting. When decrypting, please specify the ciphertext file to be decrypted. If no options are specified, it reads from standard input. -
-o
output file. Outputs to standard output if no option is specified. - Specify
-d
for decrypt mode. Encrypted mode if not specified.
Example of use
Encrypt your file secret.txt
for GitHub user octacat
and save it as sceret.zip
.
git-caesar -u octacat -i secret.txt -o secret.zip
In the same situation, the private key uses ~/.ssh/id_secret
.
git-caesar -u octacat -i secret.txt -o secret.zip -k ~/.ssh/id_secret
Decrypt GitLab user tanuki
's file secret.zip
and save it as sceret.txt
.
git-caesar -d -u https://gitlab.com/tanuki.keys -i secret.zip -o secret.txt
Same situation, no signature verification.
git-caesar -d -i secret.zip -o secret.txt
GitHub Repository
The GitHub repository is below.
Top comments (0)