DEV Community

SATO, Yoshiyuki
SATO, Yoshiyuki

Posted on

Passwordless encryption with public key for GitHub

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
  • ECDSA
    • P256 -- public key prefix: ecdsa-sha2-nistp256
    • P384 -- public key prefix: ecdsa-sha2-nistp384
    • P521 -- public key prefix: ecdsa-sha2-nistp521
  • 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
  • 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
  • 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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:
    1. Encrypt the shared key using the recipient's RSA public key.
    2. Pass the encrypted shared key to the other party
  • For ECDSA, ED25519:
    1. Generate a one-time key pair.
    2. Perform key exchange with the one-time private key and the recipient's public key.
    3. Encrypt the shared key with the exchanged key.
    4. 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
Enter fullscreen mode Exit fullscreen mode

To uninstall, run the following command.

go clean -i github.com/yoshi389111/git-caesar
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode
  • -u specifies the location of the peer's public key. Get from https://github.com/USER_NAME.keys if the one specified looks like a GitHub username. If it starts with http: or https:, 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Same situation, no signature verification.

git-caesar -d -i secret.zip -o secret.txt
Enter fullscreen mode Exit fullscreen mode

GitHub Repository

The GitHub repository is below.

https://github.com/yoshi389111/git-caesar

Top comments (0)