Artifact 1e1456716333247c6648036c61ba663d46544ce120da49328ed1da42878bc5a4:
- File
internal/clearsign/ssh.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: 4382) [annotate] [blame] [check-ins using] [more...]
package clearsign import ( "bytes" "crypto/sha256" "crypto/sha512" "encoding/base64" "encoding/binary" "errors" "fmt" "golang.org/x/crypto/ssh" "github.com/danmestas/go-libfossil/internal/deck" ) // verifySSH checks fossil's SSH envelope (src/clearsign.c): the header, a // blank line, the artifact verbatim, then the armored detached signature // ssh-keygen -Y sign wrote over it. Fossil's layout is the only one // accepted, so the signed message is exactly what a reader parses. func verifySSH(authorized string, signed []byte) error { want, err := parseSSHKey(authorized) if err != nil { return err } body := deck.StripClearsign(signed) rest, ok := bytes.CutPrefix(signed, sshHeader) if !ok || !bytes.HasPrefix(rest, body) { return fmt.Errorf("%w: not fossil's SSH envelope", ErrBadSignature) } armor := rest[len(body):] blob, err := dearmorSSH(armor) if err != nil { return err } sig, err := parseSSHSig(blob) if err != nil { return err } if !bytes.Equal(sig.publicKey, want.Marshal()) { return fmt.Errorf("%w: signed by another key", ErrBadSignature) } // PROTOCOL.sshsig forbids the SHA-1 "ssh-rsa" signature algorithm; an RSA // key signs with rsa-sha2-256 or rsa-sha2-512. if sig.signature.Format == ssh.KeyAlgoRSA { return fmt.Errorf("%w: SHA-1 ssh-rsa signature", ErrBadSignature) } if sig.namespace != SSHNamespace { return fmt.Errorf("%w: namespace %q, want %q", ErrBadSignature, sig.namespace, SSHNamespace) } var digest []byte switch sig.hash { case "sha256": d := sha256.Sum256(body) digest = d[:] case "sha512": d := sha512.Sum512(body) digest = d[:] default: return fmt.Errorf("%w: hash algorithm %q", ErrBadSignature, sig.hash) } // PROTOCOL.sshsig: the signature covers the preamble, namespace, // reserved field, hash algorithm and digest, each but the first an SSH // string. message := ssh.Marshal(struct { Namespace, Reserved, Hash string Digest []byte }{sig.namespace, sig.reserved, sig.hash, digest}) message = append([]byte("SSHSIG"), message...) if err := want.Verify(message, sig.signature); err != nil { return fmt.Errorf("%w: %v", ErrBadSignature, err) } return nil } func parseSSHKey(authorized string) (ssh.PublicKey, error) { key, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(authorized)) if err != nil { return nil, err } if len(bytes.TrimSpace(rest)) != 0 { return nil, errors.New("more than one key") } return key, nil } // dearmorSSH decodes "-----BEGIN SSH SIGNATURE-----", base64 lines, and // "-----END SSH SIGNATURE-----", with nothing after. func dearmorSSH(armor []byte) ([]byte, error) { inner, ok := bytes.CutPrefix(armor, sshSigBegin) if ok { inner, ok = bytes.CutSuffix(inner, sshSigEnd) } if !ok { return nil, fmt.Errorf("%w: malformed SSH signature armor", ErrBadSignature) } blob, err := base64.StdEncoding.DecodeString(string(bytes.ReplaceAll(inner, []byte("\n"), nil))) if err != nil { return nil, fmt.Errorf("%w: SSH signature armor: %v", ErrBadSignature, err) } return blob, nil } type sshSig struct { publicKey []byte namespace, reserved, hash string signature *ssh.Signature } // parseSSHSig reads the SSHSIG blob of PROTOCOL.sshsig: the six-byte // preamble, version 1, then the public key, namespace, reserved field, hash // algorithm and signature as SSH strings. func parseSSHSig(blob []byte) (sshSig, error) { bad := func(what string) (sshSig, error) { return sshSig{}, fmt.Errorf("%w: SSH signature: %s", ErrBadSignature, what) } rest, ok := bytes.CutPrefix(blob, []byte("SSHSIG")) if !ok || len(rest) < 4 { return bad("no SSHSIG preamble") } if v := binary.BigEndian.Uint32(rest); v != 1 { return bad(fmt.Sprintf("version %d", v)) } var fields struct { PublicKey []byte Namespace string Reserved string Hash string Signature []byte } if err := ssh.Unmarshal(rest[4:], &fields); err != nil { return bad(err.Error()) } var sig struct { Format string Blob []byte Rest []byte `ssh:"rest"` } if err := ssh.Unmarshal(fields.Signature, &sig); err != nil { return bad(err.Error()) } return sshSig{ publicKey: fields.PublicKey, namespace: fields.Namespace, reserved: fields.Reserved, hash: fields.Hash, signature: &ssh.Signature{Format: sig.Format, Blob: sig.Blob, Rest: sig.Rest}, }, nil }