Skip to content

Sign and verify a release

By the end of this you'll have signed a checksums manifest and watched a TrustSet accept it — the exact flow a consumer like afmpeg uses to verify ffmpeg-wasi's release assets. Allow about fifteen minutes.

You'll do both halves in one self-contained Go program so every moving part is visible. In a real deployment the signing half runs on the publisher's machine and the verifying half runs inside your tool; here they run side by side so the round trip is easy to follow.

What you need before you start

  • Go 1.26 or newer (go.mod declares go 1.26.5).
  • A new module to experiment in:
mkdir verify-tutorial && cd verify-tutorial
go mod init example.test/verify-tutorial
go get gitlab.com/phpboyscout/go/signing

Nothing here touches the network at run time, and nothing is destructive — the program only writes to standard output.

Generate a signing key

In production the publisher holds the private key, often inside a KMS, and you only ever see the public half. For this walkthrough you generate an RSA key so you can play both roles.

The key must be at least 3072 bits. Anything weaker is refused when the trust set is built, with verify.ErrWeakKey — see the strength policy.

priv, _ := rsa.GenerateKey(rand.Reader, 3072)
now := time.Unix(0, 0) // a fixed time keeps this example reproducible

Generating a 3072-bit key takes a second or two, occasionally longer.

now is pinned deliberately. The creation time is baked into the key and its self-signature, so pinning it makes every run produce byte-identical output. Use a real timestamp for a real key, and never move it afterwards — a rotation gets a new key, not a new timestamp on the old one.

Export the armoured public key

The publisher exports an ASCII-armoured OpenPGP public key. This is the thing you embed in your verifier at build time.

pub, _ := openpgpkey.ArmoredPublicKey(priv, "Release", "release@example.test", now)

pub is a []byte holding a -----BEGIN PGP PUBLIC KEY BLOCK----- envelope.

ArmoredPublicKey accepts any crypto.Signer whose Public() returns an *rsa.PublicKey — a local key here, a KMS handle in production. It does not mint Ed25519 OpenPGP keys: an Ed25519 signer returns openpgpkey.ErrUnsupportedKeyType.

Sign a checksums manifest

A release does not sign each asset individually. The publisher writes one checksums manifest listing a hash per asset, and signs that single file.

manifest := []byte("sha256  ffmpeg.wasm  0xc0ffee\n")
sig, _ := openpgpkey.DetachSign(priv, pub, bytes.NewReader(manifest), now)

DetachSign returns an ASCII-armoured detached signature and leaves the manifest untouched. The publisher ships manifest, sig and pub alongside the release assets.

DetachSign reads the whole reader into memory before signing, so bound the input yourself if it comes from somewhere you do not control. A checksums manifest is a few kilobytes, so it doesn't bite here.

Build a trust set and verify

Now switch hats: you're the consumer. Load the publisher's public key into a TrustSet and verify the manifest against the signature.

trust, _ := verify.LoadTrustSet(pub)
err := trust.VerifyManifestSignature(manifest, sig)
if err != nil {
    log.Fatalf("verification failed: %v", err)
}
fmt.Println("verified")

A nil error means two things: the manifest was signed by a key in the set, and it hasn't changed since. It says nothing about the assets themselves — you still hash each downloaded file and compare it to its manifest line.

The complete program

package main

import (
    "bytes"
    "crypto/rand"
    "crypto/rsa"
    "fmt"
    "log"
    "time"

    "gitlab.com/phpboyscout/go/signing/openpgpkey"
    "gitlab.com/phpboyscout/go/signing/verify"
)

func main() {
    priv, err := rsa.GenerateKey(rand.Reader, 3072)
    if err != nil {
        log.Fatal(err)
    }
    now := time.Unix(0, 0)

    pub, err := openpgpkey.ArmoredPublicKey(priv, "Release", "release@example.test", now)
    if err != nil {
        log.Fatal(err)
    }

    manifest := []byte("sha256  ffmpeg.wasm  0xc0ffee\n")
    sig, err := openpgpkey.DetachSign(priv, pub, bytes.NewReader(manifest), now)
    if err != nil {
        log.Fatal(err)
    }

    trust, err := verify.LoadTrustSet(pub)
    if err != nil {
        log.Fatal(err)
    }
    if err := trust.VerifyManifestSignature(manifest, sig); err != nil {
        log.Fatalf("verification failed: %v", err)
    }

    fmt.Println("verified")
}

Run it:

go run .

How to tell it worked

The program prints:

verified

That's the success signal. To prove the check is real, change a byte in manifest after signing — edit the checksum — and run it again. The program now exits with a verification error instead.

Where to go next