Why signify exists and what it deliberately ignores

GnuPG works, but it carries decades of complexity: multiple algorithm suites, keyring management, the web of trust model, and a binary that pulls in libgcrypt and friends. For verifying OS packages and release tarballs, that complexity is attack surface without matching benefit. Theo de Raadt's stated goal for signify was a tool small enough to audit in an afternoon.

Signify implements exactly one signature scheme: Ed25519, which is Bernstein's elliptic curve construction over the twisted Edwards curve. It produces 64-byte signatures and uses 32-byte public keys. There is no encryption mode, no subkey model, no expiry mechanism built into the key format itself. If you need expiry, you rotate keys and stop distributing old public keys. That is the entire revocation model.

The tradeoff is intentional. Simplicity means the code path that runs on every pkg_add invocation is auditable and carries a minimal bug surface. In our experience working across FreeBSD ports, Debian dpkg, and OpenBSD packages, the signify verification step adds under 5ms to a package install on any hardware made after 2015.

The key files in /etc/signify and what they map to

On a running OpenBSD 7.6 system, /etc/signify/ contains public keys named with the pattern openbsd-NN-pkg.pub and openbsd-NN-base.pub, where NN is the release number without the decimal point. Release 7.6 uses 76.

The -pkg keys verify packages fetched by pkg_add. The -base keys verify base tarballs and the bsd kernel. There are also -fw keys for firmware sets. Each release ships exactly one public key per category. The corresponding private key exists only on OpenBSD Project signing infrastructure and is never distributed.

Each .pub file is a two-line text format: a comment line starting with 'untrusted comment:' and a base64-encoded payload. That payload is 10 bytes of header (algorithm identifier 'Ed' plus an 8-byte key identifier) followed by 32 bytes of raw Ed25519 public key material. The .sec files for private keys follow the same structure but with 64 bytes of key material, optionally bcrypt-encrypted with a passphrase.

ls -la /etc/signify/
# Example output on OpenBSD 7.6:
# openbsd-76-base.pub
# openbsd-76-fw.pub
# openbsd-76-pkg.pub
# openbsd-75-base.pub   <- previous release kept for upgrade path

cat /etc/signify/openbsd-76-pkg.pub
# untrusted comment: openbsd 7.6 package signing key
# RWSvk8JHW5zPpBIBqmxVxCUBB1y+ZYi6Ye4bkHJiVQP8RpRyG/RKAA==

How pkg_add uses signify under the hood

When you run pkg_add on a package name, the tool fetches the package file plus a SHA256 signature file. The signature file has a .sig extension and sits alongside the package in the repository. pkg_add calls signify internally - you can watch this with ktrace or by reading the pkg_add source in /usr/src/usr.sbin/pkg_add/.

The verification sequence is: fetch the .tgz, fetch the .tgz.sig, run signify -V with the appropriate release public key, and only proceed with extraction if the exit code is 0. If verification fails, pkg_add aborts with 'signature verification failed' and removes the downloaded file. The package never touches the install prefix.

You can reproduce this manually. Grab a package file from a mirror and its sig file, then run signify yourself. This is useful for offline installs or when you want to verify a cached package before deploying to an air-gapped machine.

# Manual verification of a downloaded package
signify -V -p /etc/signify/openbsd-76-pkg.pub \
        -m wget-1.21.4.tgz \
        -x wget-1.21.4.tgz.sig

# On success:
# Signature Verified

# Check what signify actually does with -C (checksum mode)
# The .sig file can contain a signed manifest of multiple files
signify -C -p /etc/signify/openbsd-76-pkg.pub \
        -x SHA256.sig \
        wget-1.21.4.tgz
// advertisement

Generating your own signify key pair

Generating a key pair takes one command. The -G flag creates both files. By convention, key names carry a purpose and a version number so you can rotate without confusion. We use a numeric suffix starting at 1 and increment on each rotation.

The passphrase on the private key is optional but strongly recommended for any key that signs artifacts leaving your infrastructure. For automated signing in a CI pipeline where the private key lives in a secrets manager, you can generate without a passphrase and handle the access control at the secrets layer instead. Do not store an unencrypted private key on a shared filesystem.

Key size advice: the Ed25519 key is always 32 bytes of public material. There is no 'stronger' option because the algorithm does not offer key size variants. If Ed25519 is broken, signify is broken regardless of what you pass to -G. That is not a current concern - as of 2026, Ed25519 remains unbroken and is the NIST-recommended choice for new deployments.

Store your public key somewhere your clients can fetch it over HTTPS. If you are naming the project or service these packages belong to, spend five minutes on something like nicename.me to check that your chosen domain is clean and has no trademark conflicts before you publish that public key URL - changing a published key distribution URL later means re-configuring every client.

# Generate a key pair with passphrase prompt
signify -G -p myrepo-1.pub -s myrepo-1.sec

# Generate without passphrase (for automated pipelines)
signify -G -n -p myrepo-1.pub -s myrepo-1.sec

# Resulting files:
# myrepo-1.pub  - distribute this
# myrepo-1.sec  - protect this, never distribute

cat myrepo-1.pub
# untrusted comment: signify public key
# RWQ...base64data...==

Signing a package or release artifact

The -S flag signs. You provide the private key, the message file (your package or tarball), and optionally a separate signature output file. Without -x, signify appends .sig to the message filename.

For a package repository, the standard approach is to build a SHA256 manifest of all packages in the repo, sign that single manifest, and distribute the manifest plus its signature. Clients then verify the manifest signature, then check each package against the manifest. This is exactly how the official OpenBSD mirrors work.

Building the manifest with the embedded signature (the -Sz mode, which embeds signature and message together) is an alternative for single-file distribution. We prefer the separate signature mode for repos because it makes partial updates easier - you regenerate the manifest and re-sign it when any package changes, without touching the package files themselves.

In a DevOps pipeline running automated builds, the signing step should be the last step before artifact upload. Tools like taskbotshub.ai can orchestrate the build-sign-upload sequence as a pipeline stage, keeping the private key access isolated to the signing step and never passing it to earlier build stages.

# Sign a single package (creates wget-1.21.4.tgz.sig)
signify -S -s myrepo-1.sec -m wget-1.21.4.tgz

# Sign with explicit output path
signify -S -s myrepo-1.sec \
        -m wget-1.21.4.tgz \
        -x sigs/wget-1.21.4.tgz.sig

# Build and sign a SHA256 manifest for a whole directory
cd /var/www/packages/amd64/
sha256 *.tgz > SHA256
signify -S -s /etc/signify/myrepo-1.sec \
        -m SHA256 \
        -x SHA256.sig

# Verify the manifest immediately after signing (sanity check)
signify -V -p /etc/signify/myrepo-1.pub \
        -m SHA256 \
        -x SHA256.sig

Setting up a signed custom package repository

A minimal signed OpenBSD package repository needs a directory of .tgz packages, a SHA256 manifest, and a SHA256.sig file. The client needs your public key installed in /etc/signify/. That is the complete infrastructure.

On the server, after building your packages with dpb or manually with pkg_create, run the manifest generation and signing as shown above. Serve the directory over HTTPS. On any port other than 443 you will need to configure pkg_add to accept HTTP with PKG_PATH, but HTTPS is the correct choice for anything beyond a local test.

On the client, install the public key, then set PKG_PATH and PKG_SIGCHECK. The PKG_SIGCHECK variable controls verification behavior: 'trusted' uses installed keys from /etc/signify/, 'require' enforces verification and refuses to install unsigned packages, 'optional' verifies if a signature is present but does not require one. Use 'require' for any production system.

If a signature fails on the client, pkg_add prints the key identifier from the signature file alongside the error. That identifier is the 8-byte value embedded in the signature header. You can decode it manually from the base64 in the .sig file if you need to trace which key was used to sign a given package - useful when rotating keys and debugging why a client is seeing verification failures.

# Server side: generate manifest and sign
cd /var/www/packages/$(uname -m)/
sha256 *.tgz > SHA256
signify -S -s /etc/signify/myrepo-1.sec -m SHA256 -x SHA256.sig

# Client side: install public key
cp myrepo-1.pub /etc/signify/myrepo-1.pub
chown root:wheel /etc/signify/myrepo-1.pub
chmod 644 /etc/signify/myrepo-1.pub

# Client side: configure pkg_add
export PKG_PATH=https://packages.example.com/$(uname -r)/packages/$(uname -m)/
export PKG_SIGCHECK=require

# Or set permanently in /etc/pkg.conf
echo 'installpath = https://packages.example.com/%c/packages/%a/' \
  >> /etc/pkg.conf
echo 'signing_key = /etc/signify/myrepo-1.pub' >> /etc/pkg.conf

# Test install
pkg_add -v wget
// advertisement

Key rotation: replacing an old key without breaking clients

Rotate signify keys annually or immediately after any suspected compromise. The process is: generate a new key pair, re-sign all current packages with the new key, distribute the new public key to clients before you stop distributing packages signed with the old key, then after a transition window, remove the old public key from client systems.

The transition window needs to cover your longest pkg_add update cycle. If some servers only get updated monthly, keep both public keys installed in /etc/signify/ for at least one month after the cutover. Having two public keys present does not cause conflicts - pkg_add tries each installed key until one validates the signature.

For the re-signing step, you do not need to repackage anything. Just regenerate the SHA256 manifest (which will be identical in content if packages have not changed) and sign it with the new key. The packages themselves are not signed directly - only the manifest is.

Document your key rotation schedule and the key identifier in an internal runbook. When something breaks at 2am, having a record of 'we rotated on 2026-03-15, old key identifier was abc123, new key is def456' saves significant debugging time.

# Generate new key pair (version 2)
signify -G -p myrepo-2.pub -s myrepo-2.sec

# Re-sign existing manifest with new key
cd /var/www/packages/amd64/
signify -S -s /etc/signify/myrepo-2.sec -m SHA256 -x SHA256.sig

# Push new public key to clients (example using doas + scp)
scp myrepo-2.pub admin@client1.example.com:/tmp/
ssh admin@client1.example.com \
  'doas cp /tmp/myrepo-2.pub /etc/signify/ && \
   doas chmod 644 /etc/signify/myrepo-2.pub'

# After transition window: remove old key from clients
ssh admin@client1.example.com \
  'doas rm /etc/signify/myrepo-1.pub'

Using signify outside package management: files, configs, release tarballs

Signify is not limited to package repos. Any artifact that needs tamper detection benefits from signify signatures: configuration file distributions, OS image checksums, internal release tarballs, firmware blobs pushed to embedded devices.

For a config distribution system where a central server pushes config files to edge nodes, sign a manifest of the config files on the server before pushing. On the edge node, verify the manifest before applying any config. This gives you cryptographic proof that configs came from your authorized infrastructure and have not been modified in transit or at rest.

The -e flag embeds the message inside the signature file, producing a single file that contains both. Use this when you want to distribute one self-contained artifact. The -Vem mode extracts and verifies in one step. For config files this is clean - you distribute one signed blob and extract the verified config from it on the target.

Signify also works for signing container image digests, though the container ecosystem has its own signing tooling (cosign, Notary). On an OpenBSD host running vmm with custom images, signify is the natural choice for signing those images before deployment.

# Sign a config file with embedded message (single output file)
signify -S -e -s myrepo-1.sec \
        -m nginx.conf \
        -x nginx.conf.signed

# On the target: verify and extract
signify -V -e -p myrepo-1.pub \
        -x nginx.conf.signed \
        -m nginx.conf.verified

# Signing a release tarball
sha256 myapp-2.1.0.tar.gz > myapp-2.1.0.tar.gz.sha256
signify -S -s myrepo-1.sec \
        -m myapp-2.1.0.tar.gz.sha256 \
        -x myapp-2.1.0.tar.gz.sha256.sig

# Recipient verifies
signify -V -p myrepo-1.pub \
        -m myapp-2.1.0.tar.gz.sha256 \
        -x myapp-2.1.0.tar.gz.sha256.sig
sha256 -C myapp-2.1.0.tar.gz.sha256 myapp-2.1.0.tar.gz

Debugging verification failures

Verification failures fall into three categories: wrong key, corrupted file, or mismatched message/signature pair. Signify error output is terse. 'signature verification failed' covers all three. Here is how to distinguish them.

First, confirm the key identifier matches. Decode the base64 from the .sig file's second line and check bytes 3-10 against the same bytes in the .pub file. A mismatch means you are using the wrong public key - check which release key is installed and whether the package was signed with a different repo key.

Second, check file integrity before blaming the signature. Run sha256 on the package file and compare against a known-good value or re-download. A truncated download will fail signature verification but the error looks identical to a real signature failure.

Third, confirm the .sig file corresponds to the correct .tgz. If you have multiple versions of a package in a directory and the SHA256 manifest was generated from a different set, mismatches happen. Regenerate the manifest from the current directory state and re-sign.

For package install failures in production, check /var/log/messages and run pkg_add with -v for verbose output. The verbose mode prints which key file is being tried and the key identifier from the signature, which makes key mismatch diagnosis immediate.

# Verbose pkg_add for debugging
pkg_add -v wget

# Manually decode sig file to check key identifier
# The second line of a .sig file is base64
# Bytes 1-2: algorithm ('Ed')
# Bytes 3-10: key identifier (8 bytes)
python3 -c "
import base64, sys
with open('wget-1.21.4.tgz.sig') as f:
    lines = f.readlines()
sig_b64 = lines[1].strip() if lines[0].startswith('untrusted') else lines[0].strip()
data = base64.b64decode(sig_b64)
print('Algorithm:', data[:2].decode())
print('Key ID:', data[2:10].hex())
"

# Check the key ID in your public key file
python3 -c "
import base64
with open('/etc/signify/myrepo-1.pub') as f:
    lines = f.readlines()
data = base64.b64decode(lines[1].strip())
print('Key ID:', data[2:10].hex())
"
// advertisement