293 lines
8.0 KiB
Go
293 lines
8.0 KiB
Go
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 <private-key-path> [--pub-out <path>] | sign <file> [--key <path>] [--pub <path>] | verify <file> [--pub <path>] | checksums <dir> [--match <glob>]"
|
|
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)
|
|
}
|