Artifact bc24edec0025d8c9dee7efbb821fa0ed6d255053e8a1526658c295b6ab7bfeb6:
- File
internal/clearsign/clearsign.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: 10793) [annotate] [blame] [check-ins using] [more...]
// Package clearsign signs artifacts and verifies their signatures, in the // clear-signing envelopes of draft-fossil-artifact-format-00 §4.1.1: a PGP // clear-signed message, or the SSH envelope fossil assembles around a // detached ssh-keygen signature. // // Signing runs the repository's pgp-command as fossil and zit run it, so one // setting signs with either tool. Verification takes the signer's key: an SSH // public key is checked in process; a PGP fingerprint is checked by gpg // against the user's keyring, since an OpenPGP implementation is not worth a // dependency when every PGP signer already has gpg. package clearsign import ( "bytes" "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/repo" ) // Settings, named as fossil names them. Clearsign turns signing on (fossil // db_get_boolean, default off) and fossil's legacy Omitsign turns it off // again; PGPCommand names the signer, defaulting to DefaultCommand. SeedKey // and SeedCommand are go-libfossil's (draft-fossil-horizon-00 §5.2.4): the // key a clone verifies its origin's horizon seeds with, and the command an // origin signs its seeds with, configured apart from check-in signing. const ( SettingClearsign = "clearsign" SettingOmitsign = "omitsign" SettingPGPCommand = "pgp-command" SettingSeedKey = "horizon-seed-key" SettingSeedCommand = "horizon-seed-command" DefaultCommand = "gpg --clearsign -o " ) // SSHNamespace is the ssh-keygen -n namespace a check-in's signature must // carry. It is the one fossil's documentation gives (www/signing.md), so an // origin set up by that recipe verifies. const SSHNamespace = "fossilscm" // SeedNamespace is the namespace a horizon seed's SSH signature carries, // never SSHNamespace, so that a signature on a check-in or tag cannot stand // in for one (draft-fossil-horizon-00 §5.2.4). const SeedNamespace = "fossil-horizon-seed" // maxSignedBytes bounds what a signer may hand back: a manifest plus armor, // never a payload channel. const maxSignedBytes = 8 << 20 // runTimeout bounds one signer or gpg run, which may wait on an agent. const runTimeout = 2 * time.Minute var ( // ErrUnsigned is returned by Verify for an artifact with no envelope. ErrUnsigned = errors.New("clearsign: artifact is not signed") // ErrBadSignature is returned by Verify when the envelope does not // verify against the key, or covers other bytes than the artifact's. ErrBadSignature = errors.New("clearsign: signature does not verify") // ErrCannotVerify is returned by Verify when the verifier could not judge // the signature at all: gpg is missing, timed out, or could not run. It // says nothing about the artifact, so it is no reason to discard one. ErrCannotVerify = errors.New("clearsign: signature could not be checked") ) var ( pgpHeader = []byte("-----BEGIN PGP SIGNED MESSAGE-----\n") sshHeader = []byte("-----BEGIN SSH SIGNED MESSAGE-----\n\n") sshSigBegin = []byte("-----BEGIN SSH SIGNATURE-----\n") sshSigEnd = []byte("-----END SSH SIGNATURE-----\n") envelopeHead = [][]byte{pgpHeader[:34], sshHeader[:34]} ) // Signer runs a signing command under fossil's pgp-command contract // (src/clearsign.c): COMMAND OUTFILE INFILE writes a clear-signed INFILE to // OUTFILE, except that a command whose name starts with "ssh" is run as // COMMAND INFILE, writes a detached INFILE.sig, and the SSH envelope is // assembled around it. Namespace, when set, is the one an SSH signature the // command makes must carry; the command names it, so Sign checks it. type Signer struct { Command string Namespace string } // FromSettings returns the signer q's settings name, or nil when clearsign // is off or the legacy omitsign is on: fossil signs only then, using // pgp-command or its default (src/checkin.c, src/branch.c). func FromSettings(q db.Querier) (*Signer, error) { if q == nil { panic("clearsign.FromSettings: q must not be nil") } on, err := setting(q, SettingClearsign) if err != nil || !repo.SettingBoolean(on, false) { return nil, err } omit, err := setting(q, SettingOmitsign) if err != nil || repo.SettingBoolean(omit, false) { return nil, err } command, err := setting(q, SettingPGPCommand) if err != nil { return nil, err } if strings.TrimSpace(command) == "" { command = DefaultCommand } if falsy(command) { return nil, nil } return &Signer{Command: command}, nil } // SeedSigner returns the signer of an origin's horizon seeds, q's // horizon-seed-command bound to SeedNamespace, or nil when it is unset or // off: an origin with no seed-signing command sends seeds unsigned, even // when it signs check-ins (draft-fossil-horizon-00 §5.2.4). func SeedSigner(q db.Querier) (*Signer, error) { if q == nil { panic("clearsign.SeedSigner: q must not be nil") } command, err := setting(q, SettingSeedCommand) if err != nil || strings.TrimSpace(command) == "" || falsy(command) { return nil, err } return &Signer{Command: command, Namespace: SeedNamespace}, nil } // Sign returns plain wrapped in its envelope. The signer must not alter // what it signs: the envelope's content is checked to strip back to plain, // as zit checks it, so a dash-escaped or rewritten manifest is refused // rather than stored under a name its cards do not match. func (s *Signer) Sign(ctx context.Context, plain []byte) ([]byte, error) { if s == nil || s.Command == "" { panic("clearsign.Signer.Sign: no command") } if len(plain) == 0 || plain[len(plain)-1] != '\n' { panic("clearsign.Signer.Sign: plain must be a non-empty artifact ending in LF") } ctx, cancel := context.WithTimeout(ctx, runTimeout) defer cancel() var signed []byte var err error if isSSHCommand(s.Command) { var sig []byte if sig, err = runDetached(ctx, s.Command, plain); err == nil { signed = append(append(append([]byte(nil), sshHeader...), plain...), sig...) err = s.checkNamespace(sig) } } else { signed, err = runClearsign(ctx, s.Command, plain) } if err != nil { return nil, fmt.Errorf("clearsign: %s: %w", s.Command, err) } if envelope(signed) == nil || !bytes.Equal(deck.StripClearsign(signed), plain) { return nil, fmt.Errorf("clearsign: %s did not return %d bytes clear-signed unchanged", s.Command, len(plain)) } return signed, nil } // checkNamespace refuses an armored SSH signature made under another // namespace than the signer's, when it has one. func (s *Signer) checkNamespace(armor []byte) error { if s.Namespace == "" { return nil } blob, err := dearmorSSH(armor) if err != nil { return err } sig, err := parseSSHSig(blob) if err != nil { return err } if sig.namespace != s.Namespace { return fmt.Errorf("signed in namespace %q, want %q", sig.namespace, s.Namespace) } return nil } // Key is a signer's public identity: an SSH public key, or the fingerprint // of an OpenPGP key in the user's gpg keyring. type Key struct { ssh string // authorized_keys form, verified in process pgp string // fingerprint, upper-case hex, verified by gpg } // ParseKey reads an SSH public key line ("ssh-ed25519 AAAA… comment") or an // OpenPGP fingerprint (40 or 64 hex digits, spaces allowed). func ParseKey(s string) (Key, error) { s = strings.TrimSpace(s) if s == "" { return Key{}, errors.New("clearsign: empty key") } if fpr, ok := fingerprint(s); ok { return Key{pgp: fpr}, nil } if _, err := parseSSHKey(s); err != nil { return Key{}, fmt.Errorf("clearsign: %q is neither an SSH public key nor an OpenPGP fingerprint: %w", s, err) } return Key{ssh: s}, nil } // String is the key as ParseKey reads it back. func (k Key) String() string { if k.pgp != "" { return k.pgp } return k.ssh } // Verify checks that signed carries a valid signature by k over exactly the // bytes a reader parses (deck.StripClearsign), and returns ErrUnsigned for // an artifact with no envelope. An SSH signature must carry namespace: // SSHNamespace for a check-in, SeedNamespace for a seed. PGP has none. func (k Key) Verify(ctx context.Context, namespace string, signed []byte) error { if k.ssh == "" && k.pgp == "" { panic("clearsign.Key.Verify: zero Key") } if namespace == "" { panic("clearsign.Key.Verify: empty namespace") } head := envelope(signed) if head == nil { return ErrUnsigned } ctx, cancel := context.WithTimeout(ctx, runTimeout) defer cancel() if bytes.Equal(head, sshHeader[:34]) { if k.ssh == "" { return fmt.Errorf("%w: SSH-signed, but the key is an OpenPGP fingerprint", ErrBadSignature) } return verifySSH(k.ssh, namespace, signed) } if k.pgp == "" { return fmt.Errorf("%w: PGP-signed, but the key is an SSH key", ErrBadSignature) } return verifyPGP(ctx, k.pgp, signed) } // SeedKey returns the key q's horizon-seed-key setting names, or nil when // none is set: a clone with no key for its origin accepts an unsigned seed // (draft-fossil-horizon-00 §5.2.4). func SeedKey(q db.Querier) (*Key, error) { if q == nil { panic("clearsign.SeedKey: q must not be nil") } value, err := setting(q, SettingSeedKey) if err != nil || strings.TrimSpace(value) == "" { return nil, err } k, err := ParseKey(value) if err != nil { return nil, fmt.Errorf("setting %s: %w", SettingSeedKey, err) } return &k, nil } // envelope returns the envelope header data opens with, or nil. func envelope(data []byte) []byte { for _, h := range envelopeHead { if bytes.HasPrefix(data, h) { return h } } return nil } // isSSHCommand is fossil's test: the command word's base name starts with // "ssh" (command_basename). func isSSHCommand(command string) bool { word, _, _ := strings.Cut(strings.TrimSpace(command), " ") if i := strings.LastIndexAny(word, `/\`); i >= 0 { word = word[i+1:] } return strings.HasPrefix(word, "ssh") } // fingerprint normalizes s as an OpenPGP v4 or v5 fingerprint. func fingerprint(s string) (string, bool) { hex := strings.ToUpper(strings.ReplaceAll(s, " ", "")) if len(hex) != 40 && len(hex) != 64 { return "", false } for _, c := range hex { if (c < '0' || c > '9') && (c < 'A' || c > 'F') { return "", false } } return hex, true } func setting(q db.Querier, name string) (string, error) { var value string err := q.QueryRow("SELECT CAST(value AS TEXT) FROM config WHERE name=?", name).Scan(&value) if errors.Is(err, sql.ErrNoRows) { return "", nil } if err != nil { return "", fmt.Errorf("clearsign: setting %s: %w", name, err) } return value, nil } // falsy is fossil's is_false: the spellings that turn a command off. func falsy(v string) bool { switch strings.ToLower(strings.TrimSpace(v)) { case "off", "no", "false", "0": return true } return false }