Artifact ae07f7409a7bfc20c5b9915bef57279ea5fd7dedc08f435cb25f91078af848e2:
- File
docs/site/content/docs/concepts/signing.md
— part of check-in
[451d368554]
at
2026-10-24 13:16:26
on branch main
— feat(horizon): bind each seed to its horizon and sign seeds apart
Every seed an origin mints now carries T +horizon-seed <C> <rule> (draft-fossil-horizon-00 §5.2.1), inside the signed bytes. The card is not counted as a tag in force, Mint never derives a horizon-seed row into a seed, and Apply skips the card.
Before a clone holds a seed, signed or not, it checks the seed against its store cone (§5.2.4). The seed must carry exactly one binding card naming the store's C and rule, and its D must be no earlier than the held seed's. A seed that fails is refused as a wrongly signed one and the clone falls back to the content horizon. A deepen discards the held seed first, so the floor restarts at the new C. A held seed that is no longer bound is discarded when it is next applied.
Seeds are signed with their own horizon-seed-command and are no longer signed by clearsign and pgp-command. SSH seed signatures use the fossil-horizon-seed namespace. clearsign.Key.Verify now takes the namespace, and check-in verification keeps fossilscm.
Adds 9 conformance cases and flips HZ-5.2-8, HZ-5.2-11, HZ-5.2-12 and HZ-5.2-13 to covered. The zit interop gate moves to zit 0.7.1, whose seeds are byte-identical with ours. (user: [email protected], size: 7103) [annotate] [blame] [check-ins using] [more...]
title: Signing
weight: 80
go-libfossil signs two kinds of artifact: check-ins, as fossil and zit sign them, and the seeds an origin mints for structural horizons. Each has its own signing command, and both use the same two envelopes, a PGP clear-signed message or the SSH envelope fossil builds around a detached ssh-keygen signature (draft-fossil-artifact-format-00 §4.1.1).
Settings
| Setting | Constant | Meaning |
|---|---|---|
clearsign |
SettingClearsign |
Sign check-ins. Off by default. |
omitsign |
Fossil's legacy setting. On, it turns check-in signing off again. | |
pgp-command |
SettingPGPCommand |
The check-in signing command. Unset, it is fossil's default, gpg --clearsign -o. |
horizon-seed-command |
SettingHorizonSeedCommand |
The command an origin signs its horizon seeds with. Unset, seeds go out unsigned. |
horizon-seed-key |
SettingHorizonSeedKey |
The public key a clone verifies its origin's seeds with. |
Settings are read from the repository only. go-libfossil reads no global fossil configuration, so values set with fossil settings --global do not apply.
Signing check-ins
With clearsign on, every check-in a commit or a branch creation stores is signed. The command runs as fossil runs it:
- PGP:
COMMAND OUTFILE INFILE, which writes a clear-signed copy of the manifest. - SSH: a command whose name starts with
sshruns asCOMMAND INFILEand writes a detachedINFILE.sig, which is wrapped in fossil's SSH envelope. The namespace must befossilscm.
libfossil -R repo.fossil repo config set clearsign on
libfossil -R repo.fossil repo config set pgp-command \
"ssh-keygen -q -Y sign -n fossilscm -f ~/.ssh/id_ed25519"
The command runs through the shell, so ~ and environment variables expand. A signed check-in looks like this:
-----BEGIN SSH SIGNED MESSAGE-----
C signed
D 2026-10-23T20:45:21.089
F docs/x.md a09fd4d0...
...
Z 0e57ef44fd0eec16824d0a54ab247953
-----BEGIN SSH SIGNATURE-----
...
-----END SSH SIGNATURE-----
The stored bytes are the whole envelope, so the check-in's name is the hash of the signed bytes (§3.1). Readers strip the envelope before parsing, and the Z card covers the manifest inside it (§6.2).
To store one check-in unsigned, as fossil's --nosign does:
libfossil repo ci --nosignandlibfossil repo branch new --nosign.CommitOpts.NoSign(Repo.Commit) andCheckoutCommitOpts.NoSign(Checkout.Checkin).
What fails
- The signer fails or times out (after two minutes). The commit is abandoned and nothing is stored.
- The signer changes what it signs. The envelope must strip back to exactly the manifest that was given, as zit checks it. A dash-escaped or rewritten manifest is refused rather than stored under a name its cards do not match.
- On js/wasm no external command can run, so signing fails there.
go-libfossil does not verify check-in signatures when it reads them. To check one, take the output of libfossil repo cat and run the signer's own verifier: gpg --verify on a PGP envelope, or, for SSH, ssh-keygen -Y verify -n fossilscm -s SIG with the manifest between the header and the signature block on standard input. The conformance test TestSignedCheckin checks that fossil and zit read the signed check-ins, and that a check-in zit signs with the same SSH key verifies here.
Signing horizon seeds
An origin signs each structural horizon seed it mints with its horizon-seed-command (draft-fossil-horizon-00 §5.2.4). The command is configured apart from check-in signing: clearsign and pgp-command never sign a seed, and an origin with no seed command sends its seeds unsigned even when it signs check-ins. It follows pgp-command's contract. An SSH signer signs in the namespace fossil-horizon-seed, never fossilscm, so that a signature on a check-in or tag cannot stand in for a seed's; an origin whose seed command signs in another namespace serves no seed, and the request fails. PGP has no namespace and relies on the seed's binding card alone.
libfossil -R repo.fossil repo config set horizon-seed-command \
"ssh-keygen -q -Y sign -n fossil-horizon-seed -f ~/.ssh/id_ed25519"
The origin keeps one signature per seed in its own table, so a seed keeps its name while the tag state it records is unchanged. It signs only for a requester allowed to read, after the request is authorized. A store that cannot write, such as a read-only mirror, signs again for every request.
A clone verifies seeds against its horizon-seed-key setting:
repo, res, err := libfossil.Clone(ctx, "repo.fossil", t, libfossil.CloneOpts{
StoreCone: hc, // a cone with a structural horizon
SeedKey: "ssh-ed25519 AAAA... origin",
})
libfossil repo clone URL repo.fossil --horizon <full hash> --structural \
--seed-key "ssh-ed25519 AAAA... origin"
The key is an SSH public key line, verified in process, or a 40 or 64 digit OpenPGP fingerprint, verified by gpg against the user's keyring. Repo.SetConfig refuses a horizon-seed-key that is neither. The key is the clone's own; the origin never sends it.
The clone verifies every seed it holds: at clone time, on every pull, and when it rebuilds.
| Seed | Key set | Result |
|---|---|---|
Signed by the key, in namespace fossil-horizon-seed for SSH |
yes | Held. |
| Unsigned, signed by another key, or SSH-signed in another namespace | yes | Discarded; the store falls back to the content horizon. |
| Any | no | Held. A clone with no key accepts an unsigned seed. |
| Verifier could not run (gpg missing or timed out) | yes | The seed already held is kept and the error is reported. A new seed is refused. Nothing falls back. |
Whatever its signature, a seed is also refused when its binding card does not name the clone's own horizon or it is dated before the seed the clone holds; see the binding card.
Setting the origin's own horizon-seed-key to its public key makes it check a kept signature before serving it, so replacing the key behind an unchanged seed command takes effect at once.
A signature proves where a seed came from, not that its derivation is right (§5.2.4). repo verify --seed (Repo.VerifySeed) checks the seed against the history it summarizes.
Limits
- PGP verification needs gpg installed, with the origin's key in the keyring. On js/wasm only SSH keys verify.
- gpg-agent hangs under a very large open-file limit. A macOS shell can set
ulimit -nto 2^31. Lower it, to 4096 say, in the environment that signs or verifies with gpg.
Where the code is
signing.go: the setting constants.internal/clearsign: running the signer, the SSH envelope, SSH verification in process, gpg verification.internal/manifest/manifest.go: signing a check-in before it is stored.internal/sync/horizon_seedsign.goandinternal/horizon/seed: signing seeds at the origin and verifying them at the clone.