Skip to content

Sign artefacts with minisign

Sign a binary release artefact so that a Rust consumer — cargo-binstall at install time, rtb-update on self-update — can verify it. Those tools do not parse OpenPGP, so the armoured output of openpgpkey is unreadable to them. The minisign package emits what they do read.

This is a different job from signing a checksums manifest: there, one OpenPGP signature covers a manifest listing every asset. Here, each artefact gets its own .minisig file alongside it. Many projects publish both. For why they coexist, see Why there are two signature formats.

Get an Ed25519 signer

minisign is Ed25519-only. An RSA signer returns minisign.ErrUnsupportedKeyType, so the RSA key that signs your OpenPGP manifest cannot also sign artefacts — you need a second key.

Either key custody path works, and the calling code is identical:

# Local: an unencrypted PKCS#8 Ed25519 key the `local` backend can read.
openssl genpkey -algorithm ed25519 -out artefacts.pem
backend, err := signing.Get("local") // blank-import .../signing/local
if err != nil {
    return err
}
signer, err := backend.NewSigner(ctx, "artefacts.pem")

For production custody, use an ECC_NIST_EDWARDS25519 key in AWS KMS through the signing-aws-kms backend. Everything below is unchanged — minisign.Sign takes any crypto.Signer.

Sign an artefact

import "gitlab.com/phpboyscout/go/signing/minisign"

f, err := os.Open("tool_1.2.3_linux_amd64.tar.gz")
if err != nil {
    return err
}
defer f.Close()

sigFile, err := minisign.Sign(signer, f, minisign.Options{
    Filename: "tool_1.2.3_linux_amd64.tar.gz",
    Project:  "yourtool",
    Time:     releaseTime, // pin it — see below
})
if err != nil {
    return err
}

// Conventionally written next to the artefact.
if err := os.WriteFile("tool_1.2.3_linux_amd64.tar.gz.minisig", sigFile, 0o644); err != nil {
    return err
}

Sign streams the artefact through BLAKE2b-512, so memory stays constant however large it is. It then makes exactly two calls to the signer: one over the 64-byte digest, one over the global signature's message. Both are far below the 4096-byte cap a KMS imposes on a signing message — which is precisely why an HSM-held key can sign a multi-megabyte archive at all.

The .minisig suffix is load-bearing for rtb-update, which selects its parser by extension. cargo-binstall's signature URL is configurable and can be pointed at the same file.

Make the signature reproducible

Leave Options.Time at its zero value and the signature embeds time.Now(), so re-signing identical input produces a different file every run. Pin it — to the release tag's timestamp, or SOURCE_DATE_EPOCH — and re-signing identical input yields byte-identical output:

epoch, err := strconv.ParseInt(os.Getenv("SOURCE_DATE_EPOCH"), 10, 64)
if err != nil {
    return err
}
opts := minisign.Options{Filename: name, Time: time.Unix(epoch, 0).UTC()}

Nothing else in the file is time-dependent, so pinning Time is sufficient.

Publish the public key

Consumers need the public key as a base64 body. cargo-binstall pins that string in the crate's [package.metadata.binstall.signing] table.

pub, ok := signer.Public().(ed25519.PublicKey)
if !ok {
    return errors.New("signer is not Ed25519")
}

key, err := minisign.NewPublicKey(pub)
if err != nil {
    return err
}

fmt.Println(key.Encode()) // the bare base64 body, for pinning

keyFile, err := key.File("yourtool release key") // the two-line .pub file
if err != nil {
    return err
}

The key identifier is derived as SHA-256(public_key)[:8], so it is stable across rebuilt signing hosts and can be re-derived from the KMS public half alone. Nothing needs storing alongside the key.

Verify a signature

key, err := minisign.ParsePublicKey(pinnedPubKeyString) // bare body or whole file
if err != nil {
    return err
}

f, err := os.Open("tool_1.2.3_linux_amd64.tar.gz")
if err != nil {
    return err
}
defer f.Close()

if err := minisign.Verify(key, f, sigFile); err != nil {
    return fmt.Errorf("refusing artefact: %w", err)
}

Verify runs the same checks as minisign-verify with allow_legacy = false, in the same order: algorithm tag, key identifier, artefact signature, global signature. A file this accepts is a file cargo-binstall accepts.

Use VerifyDigest when you already hold the BLAKE2b-512 digest, or VerifyFile when the artefact is already in memory.

Choose the right comment for the right job

A signature file carries two comments, and they are not equivalent.

Comment Signed? Use it for
UntrustedComment No Human orientation only. Never make a trust decision on it.
TrustedComment Yes, by the global signature Metadata you want tamper-evident: the filename, the timestamp, the project.

Leave TrustedComment empty and it is built for you from Filename, Time and Project. Set it explicitly and Filename, Project and Time are all ignored.

Both comments are rejected if they contain a newline — which would corrupt the line-oriented format and could forge extra fields — or if they exceed the reference implementation's byte limits (1024 untrusted, 8192 trusted). Those limits are enforced here because the Rust verifiers do not enforce them: an over-long comment would verify in every test you can run and then fail against upstream minisign -V.

What this will not do

  • Sign with an RSA key. ErrUnsupportedKeyType. Keep a separate Ed25519 key for artefacts.
  • Produce or accept legacy "Ed" signatures. Only the prehashed "ED" variant is supported, in both directions. Verifying a legacy signature returns ErrUnsupportedAlgorithm — as cargo-binstall also does.
  • Generate a key pair. Bring a crypto.Signer.
  • Read or write minisign secret-key files. The private half stays wherever your backend keeps it.
  • Express expiry or revocation. minisign has no such concept. Rotating a key means publishing a new public key and re-signing.