Artifact 6a4a6515358bcc1772c667998df8b08fbaddfde009ba3df37489a4ee5340603b:
- File
internal/clearsign/signtest/signtest.go
— part of check-in
[612f2e7acc]
at
2026-10-23 20:05:29
on branch main
— feat(signing): sign check-ins and horizon seeds with gpg or ssh
go-libfossil signed nothing and verified nothing, so a structural-horizon clone could not tell a seed its origin minted from one substituted on disk (draft-fossil-horizon-00 ยง5.2.4, HZ-5.2-5 and HZ-5.2-6), and a repository with fossil's clearsign setting on stored its check-ins unsigned.
Signing follows fossil's contract, the same one zit follows. With clearsign on (and the legacy omitsign off), every check-in a commit or branch creation stores is clear-signed by pgp-command: unset, gpg --clearsign -o, run as COMMAND OUTFILE INFILE; a command named ssh* runs as COMMAND INFILE and its detached signature is wrapped in fossil's SSH envelope. A signer that fails, or returns text that does not strip back to the manifest, abandons the commit. NoSign and --nosign store one check-in unsigned.
An origin with clearsign on signs each seed it mints. It signs only after the request is authorized, outside the request's transaction, keeps one signature per seed and command so the seed's name is stable, and re-signs a kept signature its own horizon-seed-key no longer verifies. Keeping is best effort, so a read-only mirror still serves.
A clone given its origin's key (CloneOpts.SeedKey, repo clone --seed-key, kept as horizon-seed-key) verifies every seed on clone, pull and rebuild, and falls back to the content horizon from one unsigned or signed by another key. An SSH key is verified in process: namespace fossilscm, the configured key, no SHA-1 ssh-rsa, and exactly fossil's envelope. An OpenPGP fingerprint is verified by gpg, which must report one good signature by that key over text byte-equal to what the parser reads, with nothing after the signature. A verifier that cannot run keeps the held seed and fails the sync instead of discarding it.
fossil 2.28 and zit read the signed check-ins; a check-in zit signs with the same SSH key verifies here; zit clones a signing origin and holds its seed. (user: [email protected], size: 3025) [annotate] [blame] [check-ins using] [more...]
//go:build !js && !windows // Package signtest makes throwaway signing keys for tests: an ssh-keygen key, // or a gpg key in a private GNUPGHOME. A test is skipped when the tool is // not installed. package signtest import ( "context" "os" "os/exec" "path/filepath" "strings" "syscall" "testing" "time" ) // SSHKey makes an ssh-keygen key of type kind ("ed25519", "ecdsa", "rsa") and // returns the pgp-command that signs with it in namespace and its public key // line. func SSHKey(t testing.TB, kind, namespace string) (command, public string) { t.Helper() if _, err := exec.LookPath("ssh-keygen"); err != nil { t.Skip("ssh-keygen not installed") } path := filepath.Join(t.TempDir(), "id") keygen := exec.Command("ssh-keygen", "-q", "-t", kind, "-N", "", "-C", "origin", "-f", path) if out, err := keygen.CombinedOutput(); err != nil { t.Fatalf("ssh-keygen -t %s: %v: %s", kind, err, out) } pub, err := os.ReadFile(path + ".pub") if err != nil { t.Fatal(err) } return "ssh-keygen -q -Y sign -n " + namespace + " -f '" + path + "'", strings.TrimSpace(string(pub)) } // GPGKey makes a gpg home holding one ed25519 signing key, sets GNUPGHOME // for the test, and returns the pgp-command that signs with it without a // passphrase prompt and its fingerprint. func GPGKey(t *testing.T) (command, fingerprint string) { t.Helper() if _, err := exec.LookPath("gpg"); err != nil { t.Skip("gpg not installed") } // gpg-agent walks every possible descriptor when it starts, so under a // huge open-file limit (a macOS shell can set 2^31) it never finishes // starting. Setting the limit here also stops Go restoring the original // one for the processes it starts. var lim syscall.Rlimit if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim); err == nil && lim.Cur > 4096 { lim.Cur = 4096 if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil { t.Fatalf("lower the open-file limit for gpg-agent: %v", err) } } // A short home, since the agent's socket path must fit in sun_path. home, err := os.MkdirTemp("", "gpg") if err != nil { t.Fatal(err) } t.Setenv("GNUPGHOME", home) t.Cleanup(func() { _ = exec.Command("gpgconf", "--homedir", home, "--kill", "all").Run() os.RemoveAll(home) }) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() gen := exec.CommandContext(ctx, "gpg", "--batch", "--quiet", "--pinentry-mode", "loopback", "--passphrase", "", "--quick-gen-key", "Origin <[email protected]>", "ed25519", "sign", "never") if out, err := gen.CombinedOutput(); err != nil { t.Fatalf("gpg --quick-gen-key: %v: %s", err, out) } out, err := exec.Command("gpg", "--list-keys", "--with-colons").Output() if err != nil { t.Fatal(err) } for _, line := range strings.Split(string(out), "\n") { if f := strings.Split(line, ":"); len(f) > 9 && f[0] == "fpr" { return "gpg --batch --pinentry-mode loopback --passphrase '' --clearsign -o", f[9] } } t.Fatal("gpg listed no fingerprint") return "", "" }