Artifact a4b07f5da987c0ff066d23cca4f392eebad56e62b77e7948bab388520b0de7f0:
- File
internal/horizon/seed/sign.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: 5490) [annotate] [blame] [check-ins using] [more...]
package seed import ( "context" "crypto/sha256" "database/sql" "errors" "fmt" stdsync "sync" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/clearsign" "github.com/danmestas/go-libfossil/internal/repo" ) // VerifierFor returns the Verifier for the key q's horizon-seed-key setting // names (draft-fossil-horizon-00 §5.2.4), or nil when none is set: a clone // that knows no key for its origin accepts an unsigned seed. // // A seed that verified is remembered by its bytes and the key, so the // re-check every Apply makes (after each round that links artifacts) costs a // lookup, not a gpg process. func VerifierFor(q db.Querier) (Verifier, error) { key, err := clearsign.SeedKey(q) if err != nil || key == nil { return nil, err } return func(seed []byte) error { id := verifiedID(key.String(), seed) if verified.has(id) { return nil } if err := key.Verify(context.Background(), seed); err != nil { return err } verified.add(id) return nil }, nil } // verifiedMax bounds the seeds remembered as verified: a process verifies // one seed per structural store it syncs. const verifiedMax = 256 var verified = &verifiedSet{ids: map[[32]byte]struct{}{}} type verifiedSet struct { mu stdsync.Mutex ids map[[32]byte]struct{} } func verifiedID(key string, seed []byte) [32]byte { h := sha256.New() h.Write([]byte(key)) h.Write([]byte{0}) h.Write(seed) var id [32]byte copy(id[:], h.Sum(nil)) return id } func (v *verifiedSet) has(id [32]byte) bool { v.mu.Lock() defer v.mu.Unlock() _, ok := v.ids[id] return ok } func (v *verifiedSet) add(id [32]byte) { v.mu.Lock() defer v.mu.Unlock() if len(v.ids) >= verifiedMax { clear(v.ids) } v.ids[id] = struct{}{} } // signedSeedSchema keeps the seeds an origin has signed, by the unsigned // seed's name and the command that signed it. A PGP signature carries its // time, so signing the same seed twice gives two names; served from here, a // seed keeps one name while its tag state is unchanged, and a clone that // already holds it is not sent it again and re-derives nothing. const signedSeedSchema = `CREATE TABLE IF NOT EXISTS horizonseedsig( unsigned TEXT NOT NULL, command TEXT NOT NULL, signed BLOB NOT NULL, mtime INTEGER NOT NULL, PRIMARY KEY(unsigned, command) )` // signedSeedsMax bounds the table, least recently served dropped first: one // row per horizon and tag state an origin serves. const signedSeedsMax = 1024 // touchAfter is how stale a kept signature's serving time may grow before // serving it records the time again, so a seed served all day is not evicted // and a busy origin writes its row at most once in this long. const touchAfter = time.Hour // Kept is a signature an origin keeps for a seed: the signed bytes and when // it last recorded serving them. type Kept struct { Signed []byte Served time.Time } // Stale reports whether serving k should record the time again. func (k *Kept) Stale(now time.Time) bool { return now.Sub(k.Served) >= touchAfter } // KeptSignature returns the signature the origin keeps for the unsigned seed // named name under command, or nil. It only reads, so an origin can look it // up before it knows whether the requester may read. func KeptSignature(q db.Querier, name, command string) (*Kept, error) { if q == nil || name == "" || command == "" { panic("seed.KeptSignature: nil querier, name or command") } var n int if err := q.QueryRow( "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='horizonseedsig'", ).Scan(&n); err != nil || n == 0 { return nil, err } var signed []byte var mtime int64 err := q.QueryRow("SELECT signed, mtime FROM horizonseedsig WHERE unsigned=? AND command=?", name, command).Scan(&signed, &mtime) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("seed.KeptSignature: %w", err) } return &Kept{Signed: signed, Served: time.Unix(mtime, 0)}, nil } // Keep records signed as the signature of the unsigned seed named name under // command, served at now, and returns the signature kept. Unless replace is // set, one a concurrent request kept first wins, so every request names one // seed; replace drops a kept signature the origin's own key no longer // verifies. func Keep(r *repo.Repo, name, command string, signed []byte, replace bool, now time.Time) ([]byte, error) { if r == nil || name == "" || command == "" || len(signed) == 0 { panic("seed.Keep: nil repo, name, command or signature") } insert := "INSERT OR IGNORE" if replace { insert = "INSERT OR REPLACE" } var kept []byte err := r.WithTx(func(tx *db.Tx) error { if _, err := tx.Exec(signedSeedSchema); err != nil { return err } if _, err := tx.Exec(insert+` INTO horizonseedsig(unsigned, command, signed, mtime) VALUES(?, ?, ?, ?)`, name, command, signed, now.Unix()); err != nil { return err } if _, err := tx.Exec("UPDATE horizonseedsig SET mtime=? WHERE unsigned=? AND command=?", now.Unix(), name, command); err != nil { return err } if _, err := tx.Exec(`DELETE FROM horizonseedsig WHERE rowid NOT IN (SELECT rowid FROM horizonseedsig ORDER BY mtime DESC, rowid DESC LIMIT ?)`, signedSeedsMax); err != nil { return err } return tx.QueryRow("SELECT signed FROM horizonseedsig WHERE unsigned=? AND command=?", name, command).Scan(&kept) }) if err != nil { return nil, fmt.Errorf("seed.Keep: %w", err) } return kept, nil }