A golang library for Ed25519 signing, verification, encryption, and decryption for arbitrary files.
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
- 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
go get github.com/opencoff/sigtoolTo use as a library:
import "github.com/opencoff/sigtool"// 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.
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 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")
}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.
// 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()// 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 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()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()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.encThe 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.
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.
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.
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.
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.
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.
Sudhi Herle sw@herle.net
- OpenBSD signify - Ed25519 signing tool
- age - Modern file encryption tool
- FiloSottile's Ed25519→Curve25519 transform