Skip to content

Ed25519 signing, verification and encryption, decryption for arbitary files; like OpenBSD signifiy but with more functionality and written in Golang - only easier and simpler

License

opencoff/sigtool

Repository files navigation

GoDoc

sigtool

A golang library for Ed25519 signing, verification, encryption, and decryption for arbitrary files.

What is this?

sigtool is an opinionated library and CLI tool to generate keys, sign, verify, encrypt & decrypt files using Ed25519 signature scheme. In many ways, it is like like OpenBSD's signify and age -- except written in Golang, opinionated and perhaps easier to use.

It can sign and verify very large files - it prehashes the files with SHA3 and then signs the SHA3 checksum. sigtool uses mmap(2) for efficiently reading large files.

It can encrypt files for multiple recipients - each of whom is identified by their Ed25519 public key. The encryption generates ephmeral Curve25519 keys and creates pair-wise shared secret for each recipient of the encrypted file. The caller can optionally use a specific private key during the encryption process - this has the benefit of also authenticating the sender (and the receiver can verify the sender if they possess the corresponding sender's public key).

The sign, verify, encrypt, decrypt operations can use OpenSSH Ed25519 keys or the keys generated by sigtool. This means, you can send encrypted files to any recipient identified by their comment in ~/.ssh/authorized_keys.

sigtool is opinionated:

  • uses protobuf for keys and encryption headers; sigtool keys are PEM encoded protobuf content
  • uses Ed25519/X25519, AES-GCM-256, SHA3, SHA512, Argon2id

Key Features

  • Sign and verify files using Ed25519 signatures
  • Encrypt files for multiple recipients (like age)
  • Authenticated encryption with optional sender verification (like signify)
  • Works with OpenSSH Ed25519 keys - use your existing ~/.ssh/ keys
  • Efficient large file handling - uses memory-mapped I/O and SHA3 pre-hashing
  • Structured encrypted file format - protobuf headers with AEAD encryption

Installation

go get github.com/opencoff/sigtool

Quick Start

To use as a library:

import "github.com/opencoff/sigtool"

Generating Keys

// Generate a new Ed25519 keypair
sk, err := sigtool.NewPrivateKey("my keypair")
if err != nil {
    log.Fatal(err)
}

// Marshal to PEM format with encryption and user supplied function
// to get the password
out, err := sk.Marshal(getPassword)

The libray functionality described below can work with either native sigtool keys generated as above or OpenSSH Ed25519 keys.

Signing Files

A private key is used to sign a message or a file. The signature is a string of the form fingerprint.signature -- where "fingerprint" is the fingerprint of the key and signature is the Ed25519 signature of the content.

The fingerprint is a truncated hash of the corresponding public key. The fingeprint and signature are base64 encoded (Raw URL encoding).

// Sign a file
sig, err := sk.SignFile("/path/to/document.pdf")
if err != nil {
    log.Fatal(err)
}

fmt.Printf("signature: %s\n", sig)

Verifying Signatures

Verifying signatures requires a public key corresponding the private key that produced the signature.

// Read public key
pkbytes, err := os.ReadFile("/path/to/key.pub")
if err != nil {
    log.Fatal(err)
}

pk, err := sigtool.ParsePublicKey(pkbytes)
if err != nil {
    log.Fatal(err)
}

// Read signature
sig, err := os.ReadFile("/path/to/file.sig")
if err != nil {
    log.Fatal(err)
}

// Verify
ok, err := pk.VerifyFile("/path/to/document.pdf", sig)
if err != nil {
    log.Fatal("PK not valid for this signature")
}

if !ok {
    log.Fatal("signature mismatch")
}

Encrypting & Decrypting Files

Encryption in the context of sigtool always means encrypting a file for at least one recipient.

In the examples below, we ignore error handling for brevity.

Encrypting Without Sender Authentication

// Load at least one recipient's public key
rxPK, err := sigtool.ReadPublicKey("/path/to/recipient.pub")

// assume 'rd' is an io.Reader and 'wr' is an io.WriteCloser

// Let the library figure out the optimal block size.
enc, err := sigtool.NewEncryptor(nil, rxPK, rd, wr, 0)

err = enc.Encrypt()

Encrypting with Sender Authentication

// Load your private key to authenticate yourself
senderSK, err := sigtool.ReadPrivateKey("/path/to/sender.key", getPassword)

// Load at least one recipient's public key
rxPK, err := sigtool.ReadPublicKey("/path/to/recipient.pub")

// assume 'rd' is an io.Reader and 'wr' is an io.WriteCloser

// Let the library figure out the optimal block size.
enc, err := sigtool.NewEncryptor(senderSK, rxPK, rd, wr, 0)

err = enc.Encrypt()

Decrypting Files without Sender Verification

Decrypting requires the recipient to provide their secret key.

// Load your private key
sk, err := sigtool.ReadPrivateKey("/path/to/mykey.key", getPassword)

// assume 'rd' is an io.Reader and 'wr' is an io.WriteCloser

dec, err := sigtool.NewDecryptor(sk, nil, rd, wr)

err = dec.Decrypt()

Decrypting Files with Sender Verification

When verifying the sender during decryption, the decryption needs the sender's public key.

sk, err := sigtool.ReadPrivateKey("/path/to/mykey.key", getPassword)

senderPK, err := sigtool.ReadPublicKey("/path/to/sender.pub")

// assume 'rd' is an io.Reader and 'wr' is an io.WriteCloser

dec, err := sigtool.NewDecryptor(sk, senderPK, rd, wr)

err = dec.Decrypt()

Command Line Tool

A full-featured command-line interface is available at cmd/sigtool. The tool is documented in its own README.md.

The CLI provides all library functionality through an intuitive interface:

# Generate keys
sigtool gen /path/to/mykey

# Sign a file, write signature to stdout
sigtool sign /path/to/mykey.key document.pdf

# Sign a file, write signature to output file
sigtool sign -o /path/to/doc.sig /path/to/mykey.key document.pdf

# Verify a signature
sigtool verify /path/to/mykey.pub /path/to/doc.sig document.pdf

# Encrypt for multiple recipients
sigtool encrypt -s sender.key recipient1.pub recipient2.pub -o archive.enc archive.tar.gz

# Decrypt and verify sender
sigtool decrypt -v sender.pub mykey.key -o archive.tar.gz archive.enc

Technical Details

How is the file encryption done?

The file encryption uses AES-GCM-256 in AEAD mode. The encryption uses a random 32-byte AES-256 key. This root key and salt is expanded via HKDF-SHA3 into:

  • AES-GCM-256 key (32 bytes)
  • AES Nonce (12 bytes)
  • HMAC-SHA3 key (32 bytes)

The input to the HKDF is the root-key, header-checksum ("salt") and a context string.

The input is broken into chunks and each chunk is individually AEAD encrypted. The default chunk size is 4MB (4 * 1048576 bytes). We increment the nonce for each chunk. The chunk number and chunk size are part of the "AD" (additional data) of the AEAD. The last block has its most-signficant-bit set to 1 to denote EOF. Thus, the maximum chunk size is set to 1GB.

We calculate a running hmac of the plaintext blocks; when sender identity is present, the final HMAC is signed via the sender's Ed25519 key. This signature is appended as the "trailer" (last 64 bytes of the encrypted file are the Ed25519 signature).

When sender identity is not present, we generate a random looking signature.

What is the public-key cryptography in sigtool?

sigtool uses ephemeral Curve25519 keys to generate shared secrets between pairs of sender & one or more recipients. This pairwise shared secret is used as a key-encryption-key (KEK) to wrap the data-encryption key in AEAD mode. Thus, each recipient has their own individual encrypted key blob - that only they can decrypt.

If the sender authenticates the encryption by providing their secret key, the encryption key material is signed via Ed25519 and the signature is encrypted (using the data-encryption key) and stored in the header. If the sender opts to not authenticate, a "signature" of all zeroes is encrypted instead.

The Ed25519 keys generated by sigtool or OpenSSH are transformed to their corresponding Curve25519 points in order to generate the pair-wise shared secret. This elliptic co-ordinate transform follows FiloSottile's writeup.

Format of the Encrypted File

Every encrypted file starts with a header and the header-checksum:

  • Fixed-size header
  • Variable-length header
  • SHA3 sum of both of the above

The fixed length header is:

7 byte magic ("SigTool")
1 byte version number
4 byte header length (big endian encoding)

The variable length header has the per-recipient wrapped keys. This is described as a protobuf file (sign/hdr.proto):

    message header {
        uint32 chunk_size = 1;
        bytes  salt       = 2;
        bytes  pk         = 3;  // sender's ephemeral curve PK
        bytes  sender     = 4;  // ed25519 signature of key material
        repeated wrapped_key keys = 5;
    }

    /*
     * A file encryption key is wrapped by a recipient specific public
     * key. WrappedKey describes such a wrapped key.
     */
    message wrapped_key {
        bytes d_key = 1;
        bytes salt  = 2;
    }

The SHA3 sum covers the fixed-length and variable-length headers.

The encrypted data immediately follows the headers above. Each encrypted chunk is encoded the same way:

    4 byte chunk length (big endian encoding)
    AEAD encrypted chunk data
    AEAD tag

The chunk length does not include the AEAD tag length; it is implicitly computed. The chunk data and AEAD tag are treated as an atomic unit for AEAD decryption.

How is the private key protected?

The Ed25519 private key is encrypted in AES-GCM-256 mode using a key derived from the user's pass-phrase. The user pass phrase is expanded via SHA3; this expanded pass phrase is fed to argon2id() to generate a key-encryption-key. In pseudo code, this operation looks like below:

passphrase = get_user_passphrase()
expanded   = SHA3(passphrase)
salt       = randombytes(32)
key        = Argon2id(expanded, salt, Mem, Time, Proc)
esk        = AES256_GCM(ed25519_private_key, key)

Where, Mem, Time and Proc are Argon2id parameters. In our implementation:

Mem  = 64 * 1024
Time = 2
Proc = 8

The generated keys and signatures are PEM encoded protobuf format.

The signature is of the form Fingerprint.Signature - where Fingerprint is the fingerprint of the signing key and Signature is the Ed25519 signature of the content. Both are Raw URL encoded (similar to JWTs).

This allows us to easily know at verification time if a public key is the correct one for the content being verified.

Signatures on large files are calculated efficiently by reading them in memory mapped mode (mmap(2)) and hashing the file contents using SHA3. The Ed25519 signature is calculated on the file-hash.

Why Protobuf?

Unlike age's text-based format or signify's command line flags, sigtool uses protobuf for the encrypted file header because:

  • Versioning: Easy to extend with new fields
  • Compact: Binary encoding is space-efficient
  • Validation: Schema provides structure guarantees
  • Language-agnostic: Can be read by any protobuf implementation
  • Deterministic: Reproducible encoding for integrity checking

See the pkg.go.dev documentation for the complete API reference.

License

The tool and code is licensed under the terms of the GNU Public License v2.0 (strictly v2.0). If you need a commercial license or a different license, please get in touch with me.

See the file LICENSE.md for the full terms of the license.

Author

Sudhi Herle sw@herle.net

Related Projects

About

Ed25519 signing, verification and encryption, decryption for arbitary files; like OpenBSD signifiy but with more functionality and written in Golang - only easier and simpler

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •