Signing Model

Two independent trust chains, three ways to reference a key, in-process versus external signing, and what happens to your repository when you rotate a key.

There are two different questions a client can ask about a repository, and SOW answers them with two separate mechanisms. Confusing them is the most common source of “I signed it but dnf still complains”, so this page starts by pulling them apart.

Two independent trust chains

Metadata signingRPM package signing
Question answered“Is this index really from you, and unmodified?”“Is this .rpm file really from you?”
Configured bysigning.rpm.metadata, signing.deb.metadatasigning.rpm.packages
Producesrepodata/repomd.xml.asc, InRelease, Release.gpgan OpenPGP signature embedded in the package
Changes package bytesnoyes
Client settingdnf repo_gpgcheck=1, apt Signed-Bydnf gpgcheck=1
Available in Plain modenoyes, via create -S KEY

They are configured separately and can be used separately. Metadata signing alone is usually the right starting point: it authenticates the whole index in one place and requires no change to the packages you received from upstream.

Managed metadata signing is controlled entirely by sow.yml. There is no CLI override, no --sign flag on build, and no way to sign one build differently from the next. That is deliberate — a repository’s signing identity is a property of the repository, not of the command that happened to update it.

Configuration

repos:
  pigsty:
    signing:
      rpm:
        packages:
          mode: never              # never | fill | always
        metadata:
          key: "file:///secure/repo-signing.asc"
      deb:
        metadata:
          key: "file:///secure/repo-signing.asc"

RPM and DEB metadata keys are declared separately, so you can use the same key for both (as above) or split them. Each metadata block accepts an optional passphrase reference alongside key.

With a metadata key configured, every build produces the signature files, including for an empty Dist:

  • RPM, per architecture view: repodata/repomd.xml plus an ASCII-armored repodata/repomd.xml.asc
  • DEB, per Dist: Release plus a clearsigned InRelease and a detached armored Release.gpg

The clearsigned body of InRelease is identical to Release. Without a metadata key, neither signature file is generated at all — you get repomd.xml and Release and nothing else.

Three ways to reference a key

A key reference is a URI, and the scheme decides who does the signing:

ReferenceMeaningSigner
file:///absolute/path.ascan ASCII-armored private key on diskin-process Go signer
env://VAR_NAMEthe armored key material in an environment variablein-process Go signer
agent://<fingerprint>a key held by the GPG agent in your environmentexternal gpg

file:// and env:// need nothing installed — SOW signs metadata itself, which is why a repository with file:// metadata keys builds identically on macOS and inside a minimal container. agent:// delegates to your GPG agent, which is the right choice when the private key lives on a smartcard or must never touch a file. agent:// cannot be combined with a passphrase reference, because the agent owns that interaction.

A passphrase reference uses the same file:// and env:// forms.

Nothing secret is ever persisted. Configuration, SQLite, logs, JSON output, and error text hold only the reference string, the fingerprint, and the public verifier certificate. config show --all prints references and fingerprints, never key material. If a key reference is unresolvable or unusable for signing, config check says so before you run a build.

RPM package signing

signing:
  rpm:
    packages:
      mode: fill
      key: env://SOW_RPM_PACKAGE_KEY
      trusted_keys: [keys/pgdg.asc]

Three modes:

ModeBehavior
neverkeep the input bytes exactly as given
fillsign when the package is unsigned or its signature is not trusted; keep the bytes when an existing signature verifies against trusted_keys
alwaysensure the final package is validly signed by the configured key; keep the bytes if it already is, otherwise re-sign

trusted_keys automatically includes the public half of the configured key. Without a key, never is the only legal mode. fill is the default when a key is present.

Package signing always runs rpm --addsign or rpm --resign from your environment, on a private staged copy. Your input file is never modified in place, in any mode. After signing, the result is re-parsed: the embedded signature must exist, the signature-neutral digest and the NEVRA must be unchanged, and the exact public key identity is recorded before the object is allowed into the journal or the pool. The rpm binary and a working GPG environment are prerequisites for fill and always; SOW itself never holds the RPM signing private key.

Because signatures embed a timestamp, signing is not reproducible — the same unsigned RPM signed twice gives different bytes. Re-adding a package you already added would therefore look like a content conflict. SOW avoids that with a signature-neutral payload digest: a SHA-256 over the immutable header and payload, excluding the RPM signature header. If the logical coordinate already exists and the neutral digest matches, and the existing object satisfies the current policy, SOW reuses the existing final bytes instead of signing again. Repeated add of the same package is a stable no-op.

That reuse is narrow on purpose. never requires a full byte match, since that mode promises to preserve input bytes. If the payload digest differs, or the stored object does not satisfy the current signing policy, it is a hard conflict — add will not quietly re-sign a package in place under an existing coordinate. There is no --replace; if re-signing changes the bytes, bump the release, or plan a proper key-rotation workflow.

Rotating a key makes Dists dirty

A Dist’s Built configuration digest covers its format, canonical architectures, limit, exclude, and the frozen signing identity. Change a key reference or a fingerprint and the digest changes, so every affected Dist becomes dirty:

$ sow status
repository=pigsty status=dirty ready_to_copy=false revision=5 generation=4 dirty_dists=el9,trixie pending=0/0 locked=false

sow build then re-signs and produces a new generation. This is the behavior you want: a key change is a real change to what clients verify, so it must go through the normal converge-and-verify path rather than taking effect silently on the next unrelated build.

The exact public certificate identity of the current built metadata is recorded per Dist, and multiple certificate versions for the same primary fingerprint can coexist — so extending an expiry or adding a subkey does not invalidate what is already published.

Plain mode

sow create /srv/repo --sign-with 6D5C5A26C36B1F73
sow create /srv/repo --sign-with 6D5C5A26C36B1F73 --overwrite

Plain mode signs RPM package bodies only; it has no metadata signing. KEY is a 16-, 40-, or 64-hex GPG key ID or fingerprint, optionally 0x-prefixed, normalized to uppercase and passed to rpm as the _gpg_name macro. Without --overwrite, only RPMs with no parseable embedded signature are signed. With it, every retained RPM is re-signed.

--sign-with requires at least one top-level RPM. A DEB-only directory, a missing rpm binary, or an unavailable key fails before anything public changes. Crash recovery for a signing run requires exactly the same authorization flags — see Plain Flat Repositories.

What the client verifies

[pigsty-el9]
name=Pigsty EL9
baseurl=https://repo.example.com/pigsty/dists/el9/$basearch/
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://repo.example.com/keys/repo-signing.asc
Types: deb
URIs: https://repo.example.com/pigsty
Suites: trixie
Components: main
Signed-By: /etc/apt/keyrings/repo-signing.asc

repo_gpgcheck=1 makes dnf verify repomd.xml.asc; gpgcheck=1 makes it verify each package’s embedded signature. On the APT side, Signed-By makes apt verify InRelease. Both were exercised end to end — dnf on EL8, EL9 and EL10, apt on Debian 12 and 13 — with gpg --verify reporting Good on every generated signature.

sow check verifies every declared signature and file hash as part of its normal run, so a signing misconfiguration shows up before you ship rather than on a customer’s machine.

Next

Last modified: 2026-08-08: init commit (fe725aa)