Digitally Signing Files with Go
A digital signature answers two questions that a checksum cannot: who produced this file, and has anyone touched it since. A SHA-256 hash tells you the bytes are unchanged only if you already trust the channel that delivered the hash. A signature binds the bytes to the holder of a private key, so the integrity guarantee travels with the artifact itself.
Go is unusually well suited to this task. The standard library ships with crypto/ed25519, crypto/ecdsa, crypto/rsa, crypto/x509, and encoding/pem, which together cover key generation, serialization, signing, and verification without a single external dependency. This article walks through building a complete, production-shaped file signing tool: from key material to streaming hashes of multi-gigabyte files, detached signature envelopes, certificate-based trust, and the mistakes that quietly destroy the security properties you thought you had.
The cryptographic model
Signing a file almost never means running the file through a signature algorithm. Public key operations are slow and, for RSA and ECDSA, are defined over a fixed-size numeric input. The universal construction is hash-then-sign:
- Compute a cryptographic digest of the message:
h = H(m). - Sign the digest with the private key:
s = Sign(sk, h). - The verifier recomputes
H(m)and checksVerify(pk, h, s).
The security of the whole scheme collapses to the collision resistance of H. If an attacker can find m' with H(m') = H(m), your signature on m is equally a signature on m'. This is not theoretical: MD5 and SHA-1 both fell to practical collision attacks, and both were used to sign real software. Use SHA-256 or SHA-512. Do not make this parameter configurable by the untrusted party who supplies the signature — that is a downgrade attack waiting to happen.
Ed25519 is the exception to the pattern. It is defined over the full message and performs its own internal hashing with SHA-512, which is why ed25519.Sign takes the message bytes rather than a digest. For large files this matters, and we will address it later with ed25519ph.
Choosing an algorithm
Three families are realistically available in Go's standard library.
Ed25519 is the default recommendation for new systems. Keys are 32 bytes, signatures are 64 bytes, signing and verification are fast, and the implementation is deterministic and constant-time by construction. There are no parameters to get wrong: no curve choice, no padding mode, no nonce generation that can leak your key. If you control both the signer and the verifier, use Ed25519.
ECDSA with P-256 is the interoperability choice. It is what X.509 certificates, JWTs (ES256), and most hardware security modules speak. It is sound, but it is fragile in a specific way: ECDSA requires a unique random nonce per signature, and reusing a nonce across two signatures allows trivial recovery of the private key. Go's crypto/ecdsa guards against this by deriving the nonce with a hedged, RFC 6979-inspired construction, so the standard library implementation is safe even under a degraded random source. Third-party or cross-language implementations may not be.
RSA remains ubiquitous in enterprise PKI. If you must use it, use PSS padding (rsa.SignPSS), not PKCS#1 v1.5. PKCS#1 v1.5 signatures have a long history of implementation flaws — most notably the Bleichenbacher "e=3 signature forgery," where verifiers that parse the padding loosely accept forged signatures without the private key. PSS has a proof of security and no such tradition. Use at least a 3072-bit modulus for new keys; 2048 is acceptable for legacy compatibility, and anything below is not.
Generating and storing key material
Keys are generated from crypto/rand.Reader, which reads from the operating system's CSPRNG. Never substitute math/rand. The type signature accepts any io.Reader, which is a footgun the compiler will not catch for you.
package keys
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
)
// GenerateKeyPair creates a new Ed25519 key pair using the OS CSPRNG.
func GenerateKeyPair() (ed25519.PublicKey, ed25519.PrivateKey, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generating ed25519 key: %w", err)
}
return pub, priv, nil
}
// SavePrivateKey serializes the private key as PKCS#8 inside a PEM block.
// The file is created with 0600 so that it is unreadable by other users.
func SavePrivateKey(path string, priv ed25519.PrivateKey) error {
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return fmt.Errorf("marshaling private key: %w", err)
}
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
// O_EXCL prevents silently overwriting an existing key.
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("creating key file: %w", err)
}
defer file.Close()
if err := pem.Encode(file, block); err != nil {
return fmt.Errorf("encoding pem: %w", err)
}
return file.Sync()
}
// SavePublicKey serializes the public key as PKIX inside a PEM block.
func SavePublicKey(path string, pub ed25519.PublicKey) error {
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return fmt.Errorf("marshaling public key: %w", err)
}
block := &pem.Block{Type: "PUBLIC KEY", Bytes: der}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("creating public key file: %w", err)
}
defer file.Close()
if err := pem.Encode(file, block); err != nil {
return fmt.Errorf("encoding pem: %w", err)
}
return file.Sync()
}
Two details in that code are load-bearing. os.O_EXCL turns an accidental re-run of your keygen command into an error instead of a silent, unrecoverable overwrite of a production signing key. And file.Sync() forces the data to durable storage before the function returns — without it, a crash between Close and the kernel's writeback can leave you with a public key whose private counterpart no longer exists anywhere.
PKCS#8 and PKIX are the modern, algorithm-agnostic serialization formats. Using them means the loading code below works unchanged if you later switch from Ed25519 to ECDSA.
Loading keys back
package keys
import (
"crypto"
"crypto/ed25519"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
)
var ErrNotPEM = errors.New("file does not contain a valid PEM block")
// LoadPrivateKey reads a PEM-encoded PKCS#8 private key from disk.
func LoadPrivateKey(path string) (crypto.Signer, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading key file: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, ErrNotPEM
}
if block.Type != "PRIVATE KEY" {
return nil, fmt.Errorf("unexpected pem block type %q", block.Type)
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing pkcs8 key: %w", err)
}
// Every usable private key type implements crypto.Signer.
signer, ok := parsed.(crypto.Signer)
if !ok {
return nil, fmt.Errorf("key of type %T cannot sign", parsed)
}
return signer, nil
}
// LoadPublicKey reads a PEM-encoded PKIX public key and asserts Ed25519.
func LoadPublicKey(path string) (ed25519.PublicKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading public key file: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, ErrNotPEM
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing pkix key: %w", err)
}
pub, ok := parsed.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("expected ed25519 public key, got %T", parsed)
}
return pub, nil
}
Returning crypto.Signer rather than a concrete type is the single most valuable abstraction in this whole design. It is the interface implemented by ed25519.PrivateKey, *ecdsa.PrivateKey, and *rsa.PrivateKey — and also by PKCS#11 tokens, cloud KMS clients, YubiKey libraries, and HSM bindings. Code written against crypto.Signer works identically whether the key lives in a file or inside tamper-resistant hardware that never reveals it.
// The interface that makes key storage pluggable.
type Signer interface {
Public() crypto.PublicKey
Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error)
}
Hashing a file without loading it into memory
The naive approach — os.ReadFile followed by sha256.Sum256 — allocates the entire file in RAM. On a 4 GB disk image that is a hard failure on a modest container. Every Go hash implements io.Writer, so io.Copy streams the file through the hash in fixed-size chunks with constant memory.
package sign
import (
"crypto/sha256"
"fmt"
"hash"
"io"
"os"
)
// HashFile streams a file through SHA-256 and returns the raw digest.
// Memory usage is constant regardless of file size.
func HashFile(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("opening file: %w", err)
}
defer file.Close()
digest := sha256.New()
// io.Copy uses a 32 KiB buffer internally; the file is never
// fully resident in memory.
if _, err := io.Copy(digest, file); err != nil {
return nil, fmt.Errorf("hashing file: %w", err)
}
return digest.Sum(nil), nil
}
// HashReader is the generic variant, useful for stdin, network
// streams, or tar entries that have no path on disk.
func HashReader(reader io.Reader, digest hash.Hash) ([]byte, error) {
if _, err := io.Copy(digest, reader); err != nil {
return nil, fmt.Errorf("hashing stream: %w", err)
}
return digest.Sum(nil), nil
}
A subtle point about Sum(nil): it appends the digest to the slice you pass, so Sum(nil) returns a fresh slice, while Sum(existing) appends to existing. It does not reset the hash state, which occasionally surprises people who call it twice expecting two independent digests.
Ed25519ph: signing a digest with Ed25519
Plain Ed25519 requires the full message and performs two passes over it, which means buffering the entire file. RFC 8032 defines Ed25519ph ("prehashed") for exactly this case: you hash the message with SHA-512 yourself, then sign the 64-byte digest. Go exposes it through ed25519.Options.
package sign
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/sha512"
"fmt"
"io"
"os"
)
// SignFilePrehashed signs a file using Ed25519ph, which allows
// streaming the file through SHA-512 instead of buffering it.
func SignFilePrehashed(path string, priv ed25519.PrivateKey) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("opening file: %w", err)
}
defer file.Close()
hasher := sha512.New()
if _, err := io.Copy(hasher, file); err != nil {
return nil, fmt.Errorf("hashing file: %w", err)
}
digest := hasher.Sum(nil)
// Ed25519ph is selected by setting Hash to crypto.SHA512.
// The rand argument is ignored: Ed25519 signing is deterministic.
signature, err := priv.Sign(rand.Reader, digest, &ed25519.Options{
Hash: crypto.SHA512,
})
if err != nil {
return nil, fmt.Errorf("signing digest: %w", err)
}
return signature, nil
}
// VerifyFilePrehashed checks an Ed25519ph signature over a file.
func VerifyFilePrehashed(path string, pub ed25519.PublicKey, signature []byte) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening file: %w", err)
}
defer file.Close()
hasher := sha512.New()
if _, err := io.Copy(hasher, file); err != nil {
return fmt.Errorf("hashing file: %w", err)
}
return ed25519.VerifyWithOptions(hasher.Sum(nil), pub, signature, &ed25519.Options{
Hash: crypto.SHA512,
})
}
Ed25519 and Ed25519ph produce different, mutually incompatible signatures over the same file. This is deliberate — it is domain separation, and it prevents a signature made in one context from being replayed in the other. The consequence for you is that signer and verifier must agree, so record the mode in your signature format rather than assuming it.
The generic signing path
Working through crypto.Signer lets one function handle every algorithm. The catch is that each family expects different SignerOpts, so a small dispatch is unavoidable.
package sign
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"fmt"
"io"
)
// SignStream signs an arbitrary reader with any crypto.Signer.
// Ed25519 keys use Ed25519ph with SHA-512; everything else uses SHA-256.
func SignStream(reader io.Reader, signer crypto.Signer) ([]byte, string, error) {
switch signer.Public().(type) {
case ed25519.PublicKey:
signature, err := signEd25519ph(reader, signer)
return signature, "ed25519ph-sha512", err
case *ecdsa.PublicKey:
digest, err := HashReader(reader, sha256.New())
if err != nil {
return nil, "", err
}
// ECDSA ignores opts beyond the hash identifier.
signature, err := signer.Sign(rand.Reader, digest, crypto.SHA256)
return signature, "ecdsa-p256-sha256", err
case *rsa.PublicKey:
digest, err := HashReader(reader, sha256.New())
if err != nil {
return nil, "", err
}
// PSS with a salt length equal to the digest length.
opts := &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
Hash: crypto.SHA256,
}
signature, err := signer.Sign(rand.Reader, digest, opts)
return signature, "rsa-pss-sha256", err
default:
return nil, "", fmt.Errorf("unsupported key type %T", signer.Public())
}
}
The second return value — the algorithm identifier — is not decoration. A signature is meaningless without knowing which algorithm produced it, and hardcoding that knowledge on the verifier side makes key rotation to a different algorithm impossible.
A signature envelope
Emitting 64 raw bytes to file.sig works until the day you need to rotate keys, support a second algorithm, or answer "when was this signed?" A structured detached signature costs almost nothing and forecloses those problems.
package envelope
import (
"crypto"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
// Envelope is a detached signature with the metadata a verifier needs.
type Envelope struct {
Version int `json:"version"`
Algorithm string `json:"algorithm"`
KeyID string `json:"key_id"`
Digest string `json:"digest"`
Signature string `json:"signature"`
SignedAt time.Time `json:"signed_at"`
Filename string `json:"filename,omitempty"`
}
// KeyID derives a stable identifier from a public key so that a
// verifier holding several keys can select the right one.
func KeyID(pub crypto.PublicKey) (string, error) {
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", fmt.Errorf("marshaling public key: %w", err)
}
sum := sha256.Sum256(der)
// Eight bytes is ample for selection; this is not a security boundary.
return base64.RawURLEncoding.EncodeToString(sum[:8]), nil
}
// Marshal renders the envelope as indented JSON.
func (e *Envelope) Marshal() ([]byte, error) {
return json.MarshalIndent(e, "", " ")
}
One warning about the Digest and SignedAt fields: they are not signed. Only the file content is. An attacker can rewrite the timestamp freely, and the digest is a convenience for diagnostics, never an input to the verification decision. If you need a trustworthy timestamp, you need an RFC 3161 timestamping authority or a transparency log — a self-asserted clock value proves nothing.
If you want the metadata covered by the signature, sign a canonical serialization of the metadata concatenated with the file digest, and be rigorous about the canonicalization. Go's encoding/json orders struct fields by declaration and map keys lexicographically, which is deterministic in practice but is not a documented stability guarantee across versions. For anything serious, define an explicit length-prefixed encoding rather than trusting a JSON marshaler to be byte-stable forever.
Verification, and the errors that matter
package verify
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"errors"
"fmt"
"io"
)
var ErrInvalidSignature = errors.New("signature verification failed")
// VerifyStream checks a signature over a reader against a public key.
// It returns nil only when the signature is valid.
func VerifyStream(reader io.Reader, pub crypto.PublicKey, signature []byte, algorithm string) error {
switch key := pub.(type) {
case ed25519.PublicKey:
if algorithm != "ed25519ph-sha512" {
return fmt.Errorf("algorithm %q does not match key type", algorithm)
}
hasher := sha512.New()
if _, err := io.Copy(hasher, reader); err != nil {
return fmt.Errorf("hashing input: %w", err)
}
err := ed25519.VerifyWithOptions(hasher.Sum(nil), key, signature, &ed25519.Options{
Hash: crypto.SHA512,
})
if err != nil {
return ErrInvalidSignature
}
return nil
case *ecdsa.PublicKey:
if algorithm != "ecdsa-p256-sha256" {
return fmt.Errorf("algorithm %q does not match key type", algorithm)
}
hasher := sha256.New()
if _, err := io.Copy(hasher, reader); err != nil {
return fmt.Errorf("hashing input: %w", err)
}
// ASN.1 DER encoded (r, s) pair.
if !ecdsa.VerifyASN1(key, hasher.Sum(nil), signature) {
return ErrInvalidSignature
}
return nil
case *rsa.PublicKey:
if algorithm != "rsa-pss-sha256" {
return fmt.Errorf("algorithm %q does not match key type", algorithm)
}
hasher := sha256.New()
if _, err := io.Copy(hasher, reader); err != nil {
return fmt.Errorf("hashing input: %w", err)
}
opts := &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
Hash: crypto.SHA256,
}
if err := rsa.VerifyPSS(key, crypto.SHA256, hasher.Sum(nil), signature, opts); err != nil {
return ErrInvalidSignature
}
return nil
default:
return fmt.Errorf("unsupported key type %T", pub)
}
}
The explicit algorithm check is deliberate and important. Without it, an attacker who controls the envelope can request a weaker algorithm and steer your verifier down a path you never intended to expose. The verifier's policy must be fixed by the verifier, not negotiated with the artifact it is checking.
Note also ecdsa.VerifyASN1 rather than the older ecdsa.Verify, which takes r and s as separate *big.Int values and leaves the wire encoding to you. ASN.1 is what every other implementation uses; the deprecated Sign/Verify pair exists for compatibility with code written before SignASN1 was added in Go 1.15.
The comparison you must never write
A recurring bug in home-grown verification code:
// WRONG: never verify by re-signing and comparing.
func brokenVerify(digest, signature []byte, priv ed25519.PrivateKey) bool {
expected := ed25519.Sign(priv, digest)
return bytes.Equal(expected, signature)
}
This is wrong on two independent axes. First, it requires the private key on the verifier, which defeats the entire purpose of asymmetric cryptography — if the verifier has the signing key, it can forge signatures too. Second, bytes.Equal is not constant-time and can leak information through timing. For signature verification specifically, use the library's Verify function, which is designed for this. When you genuinely need to compare two secrets byte-for-byte (an HMAC tag, an API token), reach for crypto/subtle:
import "crypto/subtle"
// Constant-time comparison for secret values such as MAC tags.
func equalSecrets(a, b []byte) bool {
return subtle.ConstantTimeCompare(a, b) == 1
}
Signature verification with a public key is not actually timing-sensitive — the signature and the public key are both public — but ConstantTimeCompare costs nothing and the habit is worth having.
Binding keys to identities with X.509
Everything above verifies that a file was signed by the holder of a particular key. It says nothing about whose key it is. Bridging from key to identity requires a trust anchor. In practice this means certificates: a CA signs a statement binding a public key to a name, and verifiers trust the CA rather than enumerating every individual key.
package pki
import (
"crypto"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"time"
)
// IssueSigningCert creates a certificate for a code-signing key,
// signed by the given CA.
func IssueSigningCert(
subjectKey crypto.PublicKey,
caCert *x509.Certificate,
caKey crypto.Signer,
commonName string,
) (*x509.Certificate, error) {
// Serial numbers must be unpredictable, not sequential.
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, serialLimit)
if err != nil {
return nil, fmt.Errorf("generating serial: %w", err)
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: commonName,
Organization: []string{"Example Org"},
},
NotBefore: time.Now().Add(-5 * time.Minute), // clock skew tolerance
NotAfter: time.Now().AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageCodeSigning,
},
BasicConstraintsValid: true,
IsCA: false,
}
der, err := x509.CreateCertificate(rand.Reader, template, caCert, subjectKey, caKey)
if err != nil {
return nil, fmt.Errorf("creating certificate: %w", err)
}
return x509.ParseCertificate(der)
}
// VerifyCertChain checks a signing certificate against a trusted root
// pool and enforces the code-signing extended key usage.
func VerifyCertChain(leaf *x509.Certificate, roots *x509.CertPool) error {
opts := x509.VerifyOptions{
Roots: roots,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning},
}
if _, err := leaf.Verify(opts); err != nil {
return fmt.Errorf("certificate chain invalid: %w", err)
}
return nil
}
Two things about KeyUsages in VerifyOptions: leaving it at the zero value means ExtKeyUsageServerAuth, not "any usage." Silently accepting a TLS server certificate as a code-signing certificate is a real privilege escalation. And x509.VerifyOptions has a CurrentTime field that defaults to time.Now() — which is exactly what you want for online verification, and exactly what you do not want when verifying an old artifact whose certificate has since expired. That case needs a trusted timestamp proving the signature predates the expiry, which brings us back to RFC 3161.
Assembling the CLI
package main
import (
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"time"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "keygen":
err = cmdKeygen(os.Args[2:])
case "sign":
err = cmdSign(os.Args[2:])
case "verify":
err = cmdVerify(os.Args[2:])
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: gosign <keygen|sign|verify> [flags]")
}
func cmdSign(args []string) error {
fs := flag.NewFlagSet("sign", flag.ExitOnError)
keyPath := fs.String("key", "signing.key", "path to the private key")
outPath := fs.String("out", "", "output path (default: <file>.sig)")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("expected exactly one file to sign")
}
target := fs.Arg(0)
signer, err := keys.LoadPrivateKey(*keyPath)
if err != nil {
return err
}
file, err := os.Open(target)
if err != nil {
return err
}
defer file.Close()
signature, algorithm, err := sign.SignStream(file, signer)
if err != nil {
return err
}
keyID, err := envelope.KeyID(signer.Public())
if err != nil {
return err
}
env := &envelope.Envelope{
Version: 1,
Algorithm: algorithm,
KeyID: keyID,
Signature: base64.StdEncoding.EncodeToString(signature),
SignedAt: time.Now().UTC(),
Filename: filepath.Base(target),
}
data, err := env.Marshal()
if err != nil {
return err
}
destination := *outPath
if destination == "" {
destination = target + ".sig"
}
if err := os.WriteFile(destination, data, 0o644); err != nil {
return err
}
fmt.Printf("signed %s -> %s (key %s)\n", target, destination, keyID)
return nil
}
func cmdVerify(args []string) error {
fs := flag.NewFlagSet("verify", flag.ExitOnError)
pubPath := fs.String("pub", "signing.pub", "path to the public key")
sigPath := fs.String("sig", "", "path to the signature (default: <file>.sig)")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("expected exactly one file to verify")
}
target := fs.Arg(0)
source := *sigPath
if source == "" {
source = target + ".sig"
}
raw, err := os.ReadFile(source)
if err != nil {
return err
}
var env envelope.Envelope
if err := json.Unmarshal(raw, &env); err != nil {
return fmt.Errorf("parsing envelope: %w", err)
}
if env.Version != 1 {
return fmt.Errorf("unsupported envelope version %d", env.Version)
}
signature, err := base64.StdEncoding.DecodeString(env.Signature)
if err != nil {
return fmt.Errorf("decoding signature: %w", err)
}
pub, err := keys.LoadPublicKey(*pubPath)
if err != nil {
return err
}
file, err := os.Open(target)
if err != nil {
return err
}
defer file.Close()
if err := verify.VerifyStream(file, pub, signature, env.Algorithm); err != nil {
return err
}
fmt.Printf("OK: %s signed by key %s at %s\n",
target, env.KeyID, env.SignedAt.Format(time.RFC3339))
return nil
}
The exit codes are part of the interface. A verification failure must exit non-zero, because the overwhelmingly common consumer of this tool is a shell script or CI step that checks $?. Printing "invalid signature" and exiting 0 is an actively dangerous bug.
Testing
Signature code has an unusually clean test surface: valid signatures verify, everything else fails. The tampering cases are where the bugs hide.
package sign_test
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"testing"
)
func TestSignVerifyRoundTrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
payload := []byte("the quick brown fox jumps over the lazy dog")
signature, algorithm, err := sign.SignStream(bytes.NewReader(payload), priv)
if err != nil {
t.Fatalf("signing: %v", err)
}
if err := verify.VerifyStream(bytes.NewReader(payload), pub, signature, algorithm); err != nil {
t.Errorf("valid signature rejected: %v", err)
}
}
func TestTamperedContentFails(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
payload := []byte("original content")
signature, algorithm, err := sign.SignStream(bytes.NewReader(payload), priv)
if err != nil {
t.Fatalf("signing: %v", err)
}
tampered := []byte("modified content")
if err := verify.VerifyStream(bytes.NewReader(tampered), pub, signature, algorithm); err == nil {
t.Error("verification accepted tampered content")
}
}
func TestTamperedSignatureFails(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
payload := []byte("original content")
signature, algorithm, _ := sign.SignStream(bytes.NewReader(payload), priv)
// Flip a single bit in the signature.
corrupted := make([]byte, len(signature))
copy(corrupted, signature)
corrupted[0] ^= 0x01
if err := verify.VerifyStream(bytes.NewReader(payload), pub, corrupted, algorithm); err == nil {
t.Error("verification accepted a corrupted signature")
}
}
func TestWrongKeyFails(t *testing.T) {
_, priv, _ := ed25519.GenerateKey(rand.Reader)
otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
payload := []byte("original content")
signature, algorithm, _ := sign.SignStream(bytes.NewReader(payload), priv)
if err := verify.VerifyStream(bytes.NewReader(payload), otherPub, signature, algorithm); err == nil {
t.Error("verification accepted a signature from the wrong key")
}
}
func TestEmptyInputIsSignable(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
signature, algorithm, err := sign.SignStream(bytes.NewReader(nil), priv)
if err != nil {
t.Fatalf("signing empty input: %v", err)
}
if err := verify.VerifyStream(bytes.NewReader(nil), pub, signature, algorithm); err != nil {
t.Errorf("valid signature over empty input rejected: %v", err)
}
}
The empty-input test earns its place. An empty file has a perfectly well-defined SHA-256 digest and a perfectly valid signature, and code that special-cases zero-length input tends to do so wrongly.
Operational realities
The cryptography is the easy part. What breaks in production is everything around it.
Key storage. A private key in a PEM file on a CI runner is a private key held by anyone who can trigger a build. For anything whose compromise would be expensive, move to a KMS or HSM. Because the code above is written against crypto.Signer, the switch touches only the key-loading function.
Rotation. Signatures outlive keys. A verifier that trusts exactly one public key cannot rotate without invalidating history. Distribute a set of trusted keys, select by KeyID, and let old signatures verify against old keys during the overlap window.
Revocation. Deciding a key is compromised is easy; telling every verifier is not. This is the hardest problem in the space and is why transparency logs — where every signature is publicly recorded and monitorable — have become the modern approach for software supply chains. Sigstore is the reference implementation of these ideas, and its Go tooling is worth studying before you build your own.
What the signature actually says. A signature proves that some private key signed some bytes. It does not prove the code is safe, that the build was reproducible, or that the signer intended to endorse it. Malware is signed with valid certificates every day. Signing is a foundation for a trust decision, not the decision itself.
Summary
Go's standard library gives you everything required to sign files correctly with no external dependencies. The decisions that matter are architectural rather than cryptographic: use Ed25519 unless interoperability forces your hand; stream files through the hash rather than buffering them; program against crypto.Signer so key storage stays pluggable; put an algorithm identifier and key identifier in your signature format from day one; and never let the artifact under verification choose the verification policy. The primitives are safe by default. The system around them is where you have to do the work.