Artifact 4cc8267c76d2cb2e8bb1b80a387a0a791516fbb4ae2861ad2fa38e5f359ca008:
- File
internal/sync/horizon_seedsign.go
— 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: 4619) [annotate] [blame] [check-ins using] [more...]
package sync import ( "context" "slices" "strings" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/clearsign" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/horizon/seed" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/xfer" ) // seedSigning is the signed seed of one structural reply (draft-fossil- // horizon-00 §5.2.4), made in three steps so a request that may not read // costs its origin no signature and no write: // // - before the request's transaction, the seed's kept signature is looked // up, which only reads (newSeedSigning); // - in it, once the requester may read, the reply gets the seed's pragma // and file card with neither filled in (emitHorizonServed); // - after it, outside any transaction, the seed is signed if no kept // signature serves, the signature is kept, and the two cards are filled, // or dropped when the client offered the seed's name (finishSeedSigning). type seedSigning struct { signer *clearsign.Signer self *clearsign.Key // the origin's own horizon-seed-key, when set unsigned string // the unsigned seed's name data []byte // the unsigned seed kept *seed.Kept offered string // the seed name the request offered as held pragma *xfer.PragmaCard file *xfer.FileCard } // newSeedSigning returns the signing of the unsigned seed data, named name, // when q configures a seed-signing command, or nil: check-in signing never // signs a seed (§5.2.4). func newSeedSigning(q db.Querier, name string, data []byte, offered string) (*seedSigning, error) { signer, err := clearsign.SeedSigner(q) if err != nil || signer == nil { return nil, err } self, err := clearsign.SeedKey(q) if err != nil { return nil, err } kept, err := seed.KeptSignature(q, name, signer.Command) if err != nil { return nil, err } return &seedSigning{signer: signer, self: self, unsigned: name, data: data, kept: kept, offered: offered}, nil } // placeholder appends the seed's pragma and file card to resp, to be filled // once the seed is signed. func (s *seedSigning) placeholder(resp []xfer.Card) []xfer.Card { if s.pragma != nil { panic("seedSigning.placeholder: the reply already holds the seed's cards") } s.pragma = &xfer.PragmaCard{Name: horizonSeedPragma} s.file = &xfer.FileCard{} return append(resp, s.pragma, s.file) } // finishSeedSigning signs the seed a structural reply placed, after the // request's transaction, and fills or drops its cards. A request that failed // or rolled back sends no seed. func (h *handler) finishSeedSigning(ctx context.Context) error { if h.structural == nil || h.structural.signing == nil || h.structural.signing.pragma == nil { return nil } s := h.structural.signing if !slices.Contains(h.resp, xfer.Card(s.pragma)) { return nil // the reply was reset after the cards were placed } if h.fatal || h.aborted { h.resp = dropCards(h.resp, s.pragma, s.file) return nil } name, signed, err := s.sign(ctx, h.repo, h.clock.Now()) if err != nil { return err } if strings.EqualFold(s.offered, name) { h.resp = dropCards(h.resp, s.pragma, s.file) return nil } s.pragma.Values = []string{name} s.file.UUID, s.file.Content = name, signed return nil } // sign returns the signed seed's name and bytes: the kept signature when // there is one the origin's own key, if set, still verifies, else a new one. // Keeping is best effort: a store that cannot write, a read-only mirror say, // signs afresh for every request, and a clone re-holds the seed whenever its // name changes. func (s *seedSigning) sign(ctx context.Context, r *repo.Repo, now time.Time) (string, []byte, error) { if s.kept != nil && (s.self == nil || s.self.Verify(ctx, clearsign.SeedNamespace, s.kept.Signed) == nil) { if s.kept.Stale(now) { // Best effort: a failed touch only lets the row age toward eviction. _, _ = seed.Keep(r, s.unsigned, s.signer.Command, s.kept.Signed, false, now) } return hash.SHA3(s.kept.Signed), s.kept.Signed, nil } signed, err := s.signer.Sign(ctx, s.data) if err != nil { return "", nil, err } if kept, err := seed.Keep(r, s.unsigned, s.signer.Command, signed, s.kept != nil, now); err == nil { signed = kept } return hash.SHA3(signed), signed, nil } // dropCards returns resp without the cards drop names, by identity. func dropCards(resp []xfer.Card, drop ...xfer.Card) []xfer.Card { return slices.DeleteFunc(resp, func(c xfer.Card) bool { return slices.Contains(drop, c) }) }