Sigtool
Ed25519 signing, verification and encryption, decryption for arbitary files; like OpenBSD signifiy but with more functionality and written in Golang - only easier and simpler
Install / Use
/learn @opencoff/SigtoolREADME
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 install github.com/opencoff/sigtool/cmd/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 phras
