package releasesign import ( "RenewCMS/internal/infrastructure/update" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "flag" "fmt" "io" "os" "path/filepath" "sort" "strings" ) const ( exitOK = 0 exitFailure = 1 exitUsage = 2 signingKeyEnvVar = "RELEASE_SIGNING_KEY" signatureSuffix = ".sig" privateKeyFileMode = 0o600 publicFileMode = 0o644 repositoryMarker = ".git" checksumsFileName = "checksums.txt" usageText = "usage: releasesign keygen [--pub-out ] | sign [--key ] [--pub ] | verify [--pub ] | checksums [--match ]" publicKeyCommitHint = "commit the public key as internal/infrastructure/update/release.pub" ) var ( errInsideRepository = errors.New("the private key path is inside the repository work tree, choose a path outside it") errKeyMismatch = errors.New("the private key does not match the public key in use") errPrivateKeyFormat = errors.New("the private key file is not a base64 encoded ed25519 private key") errSignatureFormat = errors.New("the signature file is not a base64 encoded ed25519 signature") errBadSignature = errors.New("the signature does not match the file: it was changed or signed with another key") errNoKeyPath = errors.New("no private key given: pass --key or set RELEASE_SIGNING_KEY") ) func Run(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { fmt.Fprintln(stderr, usageText) return exitUsage } commands := map[string]func([]string, io.Writer) error{ "keygen": keygen, "sign": sign, "verify": verify, "checksums": checksums, } command, known := commands[args[0]] if !known { fmt.Fprintln(stderr, usageText) return exitUsage } if err := command(args[1:], stdout); err != nil { fmt.Fprintln(stderr, "releasesign:", err) return exitFailure } return exitOK } func parseArguments(name string, args []string, define func(*flag.FlagSet)) (string, error) { set := flag.NewFlagSet(name, flag.ContinueOnError) set.SetOutput(io.Discard) define(set) positional, flags := splitArguments(args) if err := set.Parse(flags); err != nil { return "", err } if len(positional) != 1 { return "", errors.New(usageText) } return positional[0], nil } func splitArguments(args []string) ([]string, []string) { var positional, flags []string for i := 0; i < len(args); i++ { if strings.HasPrefix(args[i], "--") { flags = append(flags, args[i]) if !strings.Contains(args[i], "=") && i+1 < len(args) { i++ flags = append(flags, args[i]) } continue } positional = append(positional, args[i]) } return positional, flags } func keygen(args []string, stdout io.Writer) error { var pubOut string keyPath, err := parseArguments("keygen", args, func(set *flag.FlagSet) { set.StringVar(&pubOut, "pub-out", "", "") }) if err != nil { return err } if err := refuseInsideRepository(keyPath); err != nil { return err } if _, err := os.Stat(keyPath); err == nil { return fmt.Errorf("%s already exists, it is not overwritten", keyPath) } publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { return err } encodedPrivate := base64.StdEncoding.EncodeToString(privateKey) + "\n" if err := os.WriteFile(keyPath, []byte(encodedPrivate), privateKeyFileMode); err != nil { return err } encodedPublic := update.EncodePublicKey(publicKey) if pubOut != "" { if err := os.WriteFile(pubOut, []byte(encodedPublic+"\n"), publicFileMode); err != nil { return err } } fmt.Fprintf(stdout, "private key written to %s: keep it outside the repository and never share it\n", keyPath) fmt.Fprintf(stdout, "public key: %s\n%s\n", encodedPublic, publicKeyCommitHint) return nil } func refuseInsideRepository(path string) error { absolute, err := filepath.Abs(path) if err != nil { return err } for directory := filepath.Dir(absolute); ; directory = filepath.Dir(directory) { if _, err := os.Stat(filepath.Join(directory, repositoryMarker)); err == nil { return fmt.Errorf("%w (%s)", errInsideRepository, directory) } if directory == filepath.Dir(directory) { return nil } } } func sign(args []string, stdout io.Writer) error { var keyPath, pubPath string file, err := parseArguments("sign", args, func(set *flag.FlagSet) { set.StringVar(&keyPath, "key", "", "") set.StringVar(&pubPath, "pub", "", "") }) if err != nil { return err } publicKey, err := loadPublicKey(pubPath) if err != nil { return err } privateKey, err := loadPrivateKey(keyPath) if err != nil { return err } if !publicKeyOf(privateKey).Equal(publicKey) { return errKeyMismatch } content, err := os.ReadFile(file) if err != nil { return err } signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, content)) if err := os.WriteFile(file+signatureSuffix, []byte(signature+"\n"), publicFileMode); err != nil { return err } fmt.Fprintf(stdout, "signed %s into %s\n", file, file+signatureSuffix) return nil } func verify(args []string, stdout io.Writer) error { var pubPath string file, err := parseArguments("verify", args, func(set *flag.FlagSet) { set.StringVar(&pubPath, "pub", "", "") }) if err != nil { return err } publicKey, err := loadPublicKey(pubPath) if err != nil { return err } content, err := os.ReadFile(file) if err != nil { return err } rawSignature, err := os.ReadFile(file + signatureSuffix) if err != nil { return err } signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(rawSignature))) if err != nil || len(signature) != ed25519.SignatureSize { return errSignatureFormat } if !ed25519.Verify(publicKey, content, signature) { return errBadSignature } fmt.Fprintf(stdout, "%s is signed by the release key\n", file) return nil } func checksums(args []string, stdout io.Writer) error { match := "*" dir, err := parseArguments("checksums", args, func(set *flag.FlagSet) { set.StringVar(&match, "match", "*", "") }) if err != nil { return err } paths, err := filepath.Glob(filepath.Join(dir, match)) if err != nil { return err } sort.Strings(paths) var lines strings.Builder for _, path := range paths { line, err := checksumLine(path) if err != nil { return err } lines.WriteString(line) } if lines.Len() == 0 { return fmt.Errorf("no file in %s matches %s", dir, match) } target := filepath.Join(dir, checksumsFileName) if err := os.WriteFile(target, []byte(lines.String()), publicFileMode); err != nil { return err } fmt.Fprintln(stdout, "wrote", target) return nil } func checksumLine(path string) (string, error) { name := filepath.Base(path) if name == checksumsFileName || strings.HasSuffix(name, signatureSuffix) { return "", nil } info, err := os.Stat(path) if err != nil || info.IsDir() { return "", err } content, err := os.ReadFile(path) if err != nil { return "", err } sum := sha256.Sum256(content) return fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), name), nil } func loadPublicKey(pubPath string) (ed25519.PublicKey, error) { var key ed25519.PublicKey var err error if pubPath == "" { key, err = update.EmbeddedPublicKey() } else { var content []byte content, err = os.ReadFile(pubPath) if err == nil { key, err = update.ParsePublicKey(string(content)) } } if err != nil { return nil, err } if update.IsPlaceholderPublicKey(key) { return nil, update.ErrPlaceholderPublicKey } return key, nil } func loadPrivateKey(keyPath string) (ed25519.PrivateKey, error) { if keyPath == "" { keyPath = os.Getenv(signingKeyEnvVar) } if keyPath == "" { return nil, errNoKeyPath } content, err := os.ReadFile(keyPath) if err != nil { return nil, err } decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(content))) if err != nil || len(decoded) != ed25519.PrivateKeySize { return nil, errPrivateKeyFormat } return ed25519.PrivateKey(decoded), nil } func publicKeyOf(privateKey ed25519.PrivateKey) ed25519.PublicKey { return privateKey.Public().(ed25519.PublicKey) }