Artifact 0acf5cc0509cb0a3ce113a3717a0f1b3a8410809407d4c1b5b84437f3aab677d:
- File
internal/clearsign/clearsign_test.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: 13667) [annotate] [blame] [check-ins using] [more...]
//go:build !js && !windows package clearsign import ( "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/sha512" "encoding/base64" "errors" "os/exec" "path/filepath" "strings" "testing" "golang.org/x/crypto/ssh" "github.com/danmestas/go-libfossil/internal/clearsign/signtest" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/repo" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/simio" ) // manifest is a control artifact as a seed is written: cards, then Z. var manifest = []byte("D 2026-10-23T12:00:00\n" + "T +branch 0123456789abcdef0123456789abcdef01234567 trunk\n" + "U go-libfossil\n" + "Z 00000000000000000000000000000000\n") // sshKey makes an ssh-keygen key of type kind and returns its signer, in // SSHNamespace, and its public key line. func sshKey(t *testing.T, kind string) (*Signer, string) { command, pub := signtest.SSHKey(t, kind, SSHNamespace) return &Signer{Command: command}, pub } // gpgKey makes a gpg signing key and returns its signer and fingerprint. func gpgKey(t *testing.T) (*Signer, string) { command, fpr := signtest.GPGKey(t) return &Signer{Command: command}, fpr } func mustKey(t *testing.T, s string) Key { t.Helper() k, err := ParseKey(s) if err != nil { t.Fatal(err) } return k } func sign(t *testing.T, s *Signer, plain []byte) []byte { t.Helper() signed, err := s.Sign(context.Background(), plain) if err != nil { t.Fatalf("Sign: %v", err) } if !bytes.Equal(deck.StripClearsign(signed), plain) { t.Fatalf("signed artifact does not strip back to its manifest:\n%s", signed) } return signed } // TestSSHSignVerify signs with ssh-keygen through the pgp-command contract // and verifies in process, for each key type ssh-keygen signs with. func TestSSHSignVerify(t *testing.T) { for _, kind := range []string{"ed25519", "ecdsa", "rsa"} { t.Run(kind, func(t *testing.T) { signer, pub := sshKey(t, kind) signed := sign(t, signer, manifest) if !bytes.HasPrefix(signed, sshHeader) { t.Fatalf("not fossil's SSH envelope:\n%s", signed) } if err := mustKey(t, pub).Verify(context.Background(), SSHNamespace, signed); err != nil { t.Fatalf("Verify: %v", err) } }) } } // TestSSHVerifyRefuses covers each way an SSH-signed artifact must fail: // another key, another namespace, a changed body, a changed signature, and // no signature at all. func TestSSHVerifyRefuses(t *testing.T) { signer, pub := sshKey(t, "ed25519") _, otherPub := sshKey(t, "ed25519") key := mustKey(t, pub) signed := sign(t, signer, manifest) git := &Signer{Command: strings.Replace(signer.Command, "-n "+SSHNamespace, "-n git", 1)} wrongNamespace := sign(t, git, manifest) changed := bytes.Replace(signed, []byte("trunk"), []byte("trunc"), 1) sigAt := bytes.Index(signed, sshSigBegin) + len(sshSigBegin) + 40 flipped := append([]byte(nil), signed...) flipped[sigAt] ^= 'A' ^ 'B' cases := []struct { name string key Key signed []byte want error }{ {"other key", mustKey(t, otherPub), signed, ErrBadSignature}, {"namespace", key, wrongNamespace, ErrBadSignature}, {"body", key, changed, ErrBadSignature}, {"signature", key, flipped, ErrBadSignature}, {"unsigned", key, manifest, ErrUnsigned}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if err := c.key.Verify(context.Background(), SSHNamespace, c.signed); !errors.Is(err, c.want) { t.Fatalf("Verify = %v, want %v", err, c.want) } }) } } // TestPGPSignVerify signs with gpg --clearsign, fossil's default command, // and verifies through gpg against the fingerprint. func TestPGPSignVerify(t *testing.T) { signer, fpr := gpgKey(t) signed := sign(t, signer, manifest) if !bytes.HasPrefix(signed, pgpHeader) { t.Fatalf("not a PGP clear-signed message:\n%s", signed) } if err := mustKey(t, fpr).Verify(context.Background(), SSHNamespace, signed); err != nil { t.Fatalf("Verify: %v", err) } spaced := "" for i := 0; i < len(fpr); i += 4 { spaced += strings.ToLower(fpr[i:i+4]) + " " } if err := mustKey(t, spaced).Verify(context.Background(), SSHNamespace, signed); err != nil { t.Fatalf("Verify with a spaced lower-case fingerprint: %v", err) } } // TestPGPVerifyRefuses covers trailing white space, which a clear-signature // ignores but which changes the artifact and its name; a changed card; a // fingerprint of another key; and an SSH key given for a PGP signature. func TestPGPVerifyRefuses(t *testing.T) { signer, fpr := gpgKey(t) signed := sign(t, signer, manifest) trailing := bytes.Replace(signed, []byte("U go-libfossil\n"), []byte("U go-libfossil \n"), 1) changed := bytes.Replace(signed, []byte("trunk"), []byte("trunc"), 1) _, sshPub := sshKey(t, "ed25519") cases := []struct { name string key Key signed []byte }{ {"trailing white space", mustKey(t, fpr), trailing}, {"changed card", mustKey(t, fpr), changed}, {"other fingerprint", mustKey(t, strings.Repeat("AB", 20)), signed}, {"ssh key", mustKey(t, sshPub), signed}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if err := c.key.Verify(context.Background(), SSHNamespace, c.signed); !errors.Is(err, ErrBadSignature) { t.Fatalf("Verify = %v, want ErrBadSignature", err) } }) } } // TestSignRefusesUnsignedOutput: a command that copies its input instead of // signing it, or that fails, signs nothing. func TestSignRefusesUnsignedOutput(t *testing.T) { for _, command := range []string{"cp_out() { cp \"$2\" \"$1\"; }; cp_out", "false"} { if _, err := (&Signer{Command: command}).Sign(context.Background(), manifest); err == nil { t.Errorf("Sign with %q succeeded", command) } } } func TestParseKey(t *testing.T) { _, pub := sshKey(t, "ed25519") for _, s := range []string{pub, strings.Repeat("0a", 20), strings.Repeat("0A", 32)} { if _, err := ParseKey(s); err != nil { t.Errorf("ParseKey(%q): %v", s, err) } } for _, s := range []string{"", "ssh-ed25519 notbase64", strings.Repeat("0g", 20), strings.Repeat("0a", 19)} { if _, err := ParseKey(s); err == nil { t.Errorf("ParseKey(%q) succeeded", s) } } } func TestIsSSHCommand(t *testing.T) { for command, want := range map[string]bool{ "ssh-keygen -Y sign -n fossilscm -f k": true, "/usr/bin/ssh-keygen -Y sign": true, "gpg --clearsign -o ": false, "/opt/sshtools/gpg -o": false, } { if got := isSSHCommand(command); got != want { t.Errorf("isSSHCommand(%q) = %v, want %v", command, got, want) } } } // TestPGPVerifyRefusesEnvelopes covers what a clear-signature tolerates but // the artifact parser does not read the same way: bytes after the signature, // CRLF line ends, and a second signature. Each must fail, and a changed // line must fail on the text comparison, not by chance. func TestPGPVerifyRefusesEnvelopes(t *testing.T) { signer, fpr := gpgKey(t) key := mustKey(t, fpr) signed := sign(t, signer, manifest) gen := exec.Command("gpg", "--batch", "--quiet", "--pinentry-mode", "loopback", "--passphrase", "", "--quick-gen-key", "Second <[email protected]>", "ed25519", "sign", "never") if out, err := gen.CombinedOutput(); err != nil { t.Fatalf("second key: %v: %s", err, out) } twice := sign(t, &Signer{Command: "gpg --batch --pinentry-mode loopback --passphrase '' " + "-u " + fpr + " -u Second --clearsign -o"}, manifest) cases := map[string][]byte{ "after the signature": append(append([]byte(nil), signed...), "C extra\n"...), "CRLF": bytes.ReplaceAll(signed, []byte("\n"), []byte("\r\n")), "two signatures": twice, } for name, data := range cases { t.Run(name, func(t *testing.T) { if err := key.Verify(context.Background(), SSHNamespace, data); !errors.Is(err, ErrBadSignature) { t.Fatalf("Verify = %v, want ErrBadSignature", err) } }) } trailing := bytes.Replace(signed, []byte("U go-libfossil\n"), []byte("U go-libfossil \n"), 1) if err := key.Verify(context.Background(), SSHNamespace, trailing); err == nil || !strings.Contains(err.Error(), "differs") { t.Fatalf("trailing white space: Verify = %v, want the signed text to differ", err) } } // TestPGPCannotVerify: with no gpg to run, Verify says it could not judge // the signature, not that it is bad. func TestPGPCannotVerify(t *testing.T) { signer, fpr := gpgKey(t) signed := sign(t, signer, manifest) t.Setenv("PATH", t.TempDir()) err := mustKey(t, fpr).Verify(context.Background(), SSHNamespace, signed) if !errors.Is(err, ErrCannotVerify) || errors.Is(err, ErrBadSignature) { t.Fatalf("Verify without gpg = %v, want ErrCannotVerify alone", err) } } // TestSignRefusesDashEscaping: gpg dash-escapes a line that starts with a // dash, so the signed text would not strip back to the artifact. func TestSignRefusesDashEscaping(t *testing.T) { signer, _ := gpgKey(t) if _, err := signer.Sign(context.Background(), []byte("-dash\n")); err == nil { t.Fatal("Sign accepted a dash-escaped artifact") } } // TestSSHVerifyRefusesSHA1RSA: an SSHSIG made with the SHA-1 ssh-rsa // algorithm, which PROTOCOL.sshsig forbids, does not verify even when the // RSA key and bytes are right. func TestSSHVerifyRefusesSHA1RSA(t *testing.T) { priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } signer, err := ssh.NewSignerFromKey(priv) if err != nil { t.Fatal(err) } digest := sha512.Sum512(manifest) tail := ssh.Marshal(struct { Namespace, Reserved, Hash string Digest []byte }{SSHNamespace, "", "sha512", digest[:]}) sig, err := signer.(ssh.AlgorithmSigner).SignWithAlgorithm(rand.Reader, append([]byte("SSHSIG"), tail...), ssh.KeyAlgoRSA) if err != nil { t.Fatal(err) } blob := append([]byte("SSHSIG"), ssh.Marshal(struct { Version uint32 PublicKey []byte Namespace string Reserved string Hash string Signature []byte }{1, signer.PublicKey().Marshal(), SSHNamespace, "", "sha512", ssh.Marshal(sig)})...) armor := "-----BEGIN SSH SIGNATURE-----\n" + base64.StdEncoding.EncodeToString(blob) + "\n-----END SSH SIGNATURE-----\n" signed := append(append(append([]byte(nil), sshHeader...), manifest...), armor...) key := mustKey(t, string(ssh.MarshalAuthorizedKey(signer.PublicKey()))) if err := key.Verify(context.Background(), SSHNamespace, signed); !errors.Is(err, ErrBadSignature) || !strings.Contains(err.Error(), "SHA-1") { t.Fatalf("Verify = %v, want the SHA-1 signature refused", err) } } // TestSSHNamespaceIsTheVerifiers: the namespace an SSH signature must carry // is the verifier's parameter, so a check-in's fossilscm signature never // verifies as a seed's, nor a seed's as a check-in's // (draft-fossil-horizon-00 §5.2.4). func TestSSHNamespaceIsTheVerifiers(t *testing.T) { checkin, pub := sshKey(t, "ed25519") key := mustKey(t, pub) seedSigner := &Signer{Command: strings.Replace(checkin.Command, "-n "+SSHNamespace, "-n "+SeedNamespace, 1)} byCheckin, bySeed := sign(t, checkin, manifest), sign(t, seedSigner, manifest) for _, c := range []struct { name, namespace string signed []byte ok bool }{ {"check-in as check-in", SSHNamespace, byCheckin, true}, {"seed as seed", SeedNamespace, bySeed, true}, {"check-in as seed", SeedNamespace, byCheckin, false}, {"seed as check-in", SSHNamespace, bySeed, false}, } { err := key.Verify(context.Background(), c.namespace, c.signed) if c.ok && err != nil || !c.ok && !errors.Is(err, ErrBadSignature) { t.Errorf("%s: Verify = %v, want ok %v", c.name, err, c.ok) } } } // TestSignRefusesAnotherNamespace: a signer bound to a namespace refuses an // SSH signature its command made under another, so an origin whose seed // command names fossilscm sends no seed a clone could only refuse. func TestSignRefusesAnotherNamespace(t *testing.T) { checkin, _ := sshKey(t, "ed25519") wrong := &Signer{Command: checkin.Command, Namespace: SeedNamespace} if _, err := wrong.Sign(context.Background(), manifest); err == nil || !strings.Contains(err.Error(), SeedNamespace) { t.Fatalf("Sign under %s for a %s signer = %v, want a refusal naming %s", SSHNamespace, SeedNamespace, err, SeedNamespace) } right := &Signer{Command: strings.Replace(checkin.Command, "-n "+SSHNamespace, "-n "+SeedNamespace, 1), Namespace: SeedNamespace} sign(t, right, manifest) } // TestSeedSignerIsItsOwnSetting: an origin's seed-signing command is // configured apart from its check-in signing (§5.2.4): clearsign and // pgp-command sign no seed, and the seed command signs whatever they say. func TestSeedSignerIsItsOwnSetting(t *testing.T) { r, err := repo.Create(filepath.Join(t.TempDir(), "origin.fossil"), "u", simio.CryptoRand{}, "") if err != nil { t.Fatal(err) } defer r.Close() set := func(name, value string) { t.Helper() if err := r.SetConfig(name, value); err != nil { t.Fatal(err) } } seedSigner := func() *Signer { t.Helper() s, err := SeedSigner(r.DB()) if err != nil { t.Fatal(err) } return s } set(SettingClearsign, "on") set(SettingPGPCommand, "gpg --clearsign -o") if s := seedSigner(); s != nil { t.Fatalf("check-in signing alone gives seed signer %+v, want none", s) } const command = "ssh-keygen -q -Y sign -n fossil-horizon-seed -f k" set(SettingSeedCommand, command) set(SettingClearsign, "off") if s := seedSigner(); s == nil || s.Command != command || s.Namespace != SeedNamespace { t.Fatalf("seed signer %+v, want %q in namespace %s", s, command, SeedNamespace) } set(SettingSeedCommand, "off") if s := seedSigner(); s != nil { t.Fatalf("seed command off gives signer %+v", s) } }