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](/docs/concepts/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 `ssh` runs as `COMMAND INFILE` and writes a detached `INFILE.sig`, which is wrapped in fossil's SSH envelope. The namespace must be `fossilscm`. ``` 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 --nosign` and `libfossil repo branch new --nosign`. - `CommitOpts.NoSign` (`Repo.Commit`) and `CheckoutCommitOpts.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](/docs/concepts/horizons/#the-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: ```go 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](/docs/concepts/horizons/#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 -n` to 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.go` and `internal/horizon/seed`: signing seeds at the origin and verifying them at the clone.