~welt/murse

54e94ce328fa43ec70c5c0ffee666ee50a5b9066 — welt 2 months ago e27564a
Intitial experiment, verification does not support signing.
M client.go => client.go +30 -10
@@ 1,6 1,7 @@
package main

import (
	"crypto/ecdsa"
	"crypto/tls"
	"encoding/json"
	"errors"


@@ 10,15 11,18 @@ import (
	"strings"
)

// If it returns a Reader, it means theyy
type Client struct {
	key  *ecdsa.PublicKey
	repo string
	h    *http.Client
}

func NewClient(repo string) *Client {
func NewClient(repo string, key *ecdsa.PublicKey) *Client {
	return &Client{
		repo: repo,
		h:    &http.Client{},
		key:  key,
	}
}



@@ 55,32 59,48 @@ func (c *Client) getRead(url string) ([]byte, error) {
		return bytes, err
	}

	defer body.Close()

	return io.ReadAll(body)
}

func (c *Client) GetRevision(v int) ([]Change, error) {
	var changes []Change
	resp, err := c.get(c.repo + "revisions/" + strconv.Itoa(v))
	var revision Revision
	object, err := c.getRead(c.repo + "revisions/" + strconv.Itoa(v))
	if err != nil {
		return changes, err
		return nil, err
	}

	defer resp.Close()
	if c.key != nil {
		sig, err := c.getRead(c.repo + "revisions/" + strconv.Itoa(v) + ".sig")
		if err != nil {
			return nil, err
		}

	err = json.NewDecoder(resp).Decode(&changes)
		ok := verifyContents(c.key, object, sig)
		if !ok {
			return nil, errors.New("failed cryptographic check")
		}
	}

	err = json.Unmarshal(object, &revision)
	if err != nil {
		return changes, err
		return nil, err
	}

	return changes, err
	if revision.Version != v {
		return nil, errors.New("invalid version sent by server")
	}

	return revision.Changes, err
}

func (c *Client) GetObject(object string) (io.ReadCloser, error) {
	return c.get(c.repo + "objects/" + object)
}

func (c *Client) GetObjectSig(object string) ([]byte, error) {
	return c.getRead(c.repo + "objects/" + object + ".sig")
}

func (c *Client) GetLatestRevision() (int, error) {
	b, err := c.getRead(c.repo + "revisions/latest")
	if err != nil {

M const.go => const.go +1 -0
@@ 8,4 8,5 @@ const (
	DESC_REPO    = "URL of the TVS repository."
	DESC_FILES   = "Directory of game files."
	DESC_DRY     = "Runs the command, but doesn't write any changes."
	DESC_FS_PIPE = "When downloading files pipe directly to the filesystem instead of using the temporary directory. Mutually excusive with --check-signature"
)

A crypto.go => crypto.go +61 -0
@@ 0,0 1,61 @@
package main

import (
	"crypto/ecdsa"
	"crypto/md5"
	"crypto/sha256"
	"crypto/x509"
	"encoding/hex"
	"encoding/pem"
	"errors"
	"io"
)

func verifyContents(key *ecdsa.PublicKey, content []byte, signature []byte) bool {
	hash := sha256.Sum256(content)
	return ecdsa.VerifyASN1(key, hash[:], signature)
}

// First value is the signature check, the second is the MD5 check
func verifyReaderMD5(key *ecdsa.PublicKey, r io.Reader, signature []byte, md5h string) (bool, bool, error) {
	hasher := md5.New()
	tee := io.TeeReader(r, hasher)
	ok, err := verifyReader(key, tee, signature)
	if err != nil {
		return false, false, err
	}

	if hex.EncodeToString(hasher.Sum(nil)) != md5h {
		return ok, false, errors.New("file failed hash check")
	}

	return ok, true, err
}

func verifyReader(key *ecdsa.PublicKey, r io.Reader, signature []byte) (bool, error) {
	hasher := sha256.New()
	_, err := io.Copy(hasher, r)
	if err != nil {
		return false, err
	}

	return ecdsa.VerifyASN1(key, hasher.Sum(nil), signature), err
}

func readPublicKey(key []byte) (*ecdsa.PublicKey, error) {
	block, _ := pem.Decode(key)
	if block == nil || block.Type != "PUBLIC KEY" {
		return nil, errors.New("pem: invalid data")
	}

	unparsed, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		return nil, err
	}

	if parsed, ok := unparsed.(*ecdsa.PublicKey); ok {
		return parsed, nil
	}

	return nil, err
}

M main.go => main.go +4 -2
@@ 2,6 2,7 @@ package main

import (
	"os"
	"path/filepath"

	"github.com/integrii/flaggy"
)


@@ 14,13 15,14 @@ func main() {
	var http1 bool
	var repair bool
	var owGameInfo bool

	var key string
	upgrade := flaggy.NewSubcommand("upgrade")
	upgrade.Description = DESC_UPGRADE
	upgrade.String(&url, "u", "url", DESC_REPO)
	upgrade.Int(&threads, "c", "threads", "Number of threads to use for downloading, minimum two.")
	upgrade.Bool(&http1, "1", "http1", DESC_HTTP1)
	upgrade.Bool(&owGameInfo, "G", "overwrite-gameinfo", "When upgrading in a directory named open_fortress, overwrite any existing gameinfo.txt file.")
	upgrade.String(&key, "k", "key", "Verifies the files with this key, quarantining downloaded files in a directory under "+filepath.Dir(quarentineDir)+".")
	upgrade.AddPositionalValue(&dir, "directory", 1, true, DESC_FILES)
	flaggy.AttachSubcommand(upgrade, 1)



@@ 50,7 52,7 @@ func main() {
			flaggy.ShowHelp("Must have at least two threads.")
			os.Exit(1)
		}
		os.Exit(upgradeMain(dir, url, threads, http1, owGameInfo))
		os.Exit(upgradeMain(dir, url, threads, http1, owGameInfo, key))
	}

	if verify.Used {

A quarantine.go => quarantine.go +11 -0
@@ 0,0 1,11 @@
package main

import "os"

func mkQuar() error {
	return os.MkdirAll(quarentineDir, 0777)
}

func rmQuar() error {
	return os.RemoveAll(quarentineDir)
}

A quarantine_openbsd.go => quarantine_openbsd.go +9 -0
@@ 0,0 1,9 @@
package main

import (
	"strconv"
	"time"
)

// OpenBSD doesn't have tmpfs, yay!
var quarentineDir = "/tmp/murse-" + strconv.FormatInt(time.Now().UnixMicro(), 10)

A quarantine_other.go => quarantine_other.go +12 -0
@@ 0,0 1,12 @@
//go:build !linux && !freebsd && !dragonfly && !darwin && !solaris && !openbsd

package main

import (
	"os"
	"path/filepath"
	"strconv"
	"time"
)

var quarentineDir string = filepath.Join(os.TempDir(), "murse-"+strconv.FormatInt(time.Now().UnixMicro(), 10))

A quarantine_unix.go => quarantine_unix.go +11 -0
@@ 0,0 1,11 @@
//go:build linux || darwin || freebsd || dragonfly || solaris

package main

import (
	"strconv"
	"time"
)

// We use /var/tmp because it's pretty much never a ramdisk.
var quarentineDir = "/var/tmp/murse-" + strconv.FormatInt(time.Now().UnixMicro(), 10)

M toaster.go => toaster.go +20 -9
@@ 1,7 1,10 @@
package main

import (
	"bytes"
	"crypto/md5"
	"encoding/base64"
	"encoding/binary"
	"encoding/hex"
	"encoding/json"
	"fmt"


@@ 41,7 44,9 @@ func jFile2Change(bpath string, path string, folder bool, chch chan Change) erro
	io.Copy(hasher, file)
	hash := hasher.Sum(nil)

	objectid := sfnode.Generate().Base64()
	idSlice := &bytes.Buffer{}
	binary.Write(idSlice, binary.LittleEndian, sfnode.Generate())
	objectid := base64.RawURLEncoding.EncodeToString(idSlice.Bytes())

	chch <- Change{
		Type:   TYPE_WRITE,


@@ 150,14 155,14 @@ func generateCumlCache(dir string) error {
		}
		defer file.Close()

		var change []Change
		var revision Revision

		err = json.NewDecoder(file).Decode(&change)
		err = json.NewDecoder(file).Decode(&revision)
		if err != nil {
			debug.PrintStack()
			return err
		}
		revisions = append(revisions, change)
		revisions = append(revisions, revision.Changes)
	}

	cuml := cumlcache{


@@ 297,15 302,15 @@ func toastMain(filesdir string, tvsdir string, dry bool) int {
	}

	errPrintln("Comparing changes...")
	revision := compareAccuChanges(cuml.Changes, fscuml)
	if len(revision) == 0 {
	changes := compareAccuChanges(cuml.Changes, fscuml)
	if len(changes) == 0 {
		errPrintln("No changes were found.")
		return 1
	}

	if !dry {
		errPrintln("Copying objects...")
		for _, v := range revision {
		for _, v := range changes {
			if v.Type == TYPE_WRITE {
				old, err := os.Open(filepath.Join(filesdir, v.Path))
				if err != nil {


@@ 337,7 342,13 @@ func toastMain(filesdir string, tvsdir string, dry bool) int {
		}

		errPrintln("Writing revision...")
		b, err := json.Marshal(revision)

		rev := &Revision{
			Version: cuml.Version + 1,
			Changes: changes,
		}

		b, err := json.Marshal(rev)
		if err != nil {
			debug.PrintStack()
			errPrintln(err)


@@ 379,7 390,7 @@ func toastMain(filesdir string, tvsdir string, dry bool) int {
		}
	}

	for _, v := range revision {
	for _, v := range changes {
		switch v.Type {
		case TYPE_WRITE:
			fmt.Println("WRITE", v.Path)

M upgrade.go => upgrade.go +87 -13
@@ 1,6 1,8 @@
package main

import (
	"crypto/ecdsa"
	"errors"
	"fmt"
	"io"
	"os"


@@ 10,7 12,7 @@ import (
	"strings"
)

func upgradeMain(dir string, url string, threads int, http1 bool, owGameInfo bool) int {
func upgradeMain(dir string, url string, threads int, http1 bool, owGameInfo bool, keypath string) int {
	if !strings.HasSuffix(url, "/") {
		url = url + "/"
	}


@@ 21,7 23,31 @@ func upgradeMain(dir string, url string, threads int, http1 bool, owGameInfo boo
		return 1
	}

	client := NewClient(url)
	var key *ecdsa.PublicKey
	checkSigs := (keypath != "")
	if checkSigs {
		f, err := os.ReadFile(keypath)
		if err != nil {
			errPrintln(err)
			return 1
		}

		key, err = readPublicKey(f)
		if err != nil {
			errPrintln(err)
			return 1
		}

		err = mkQuar()
		if err != nil {
			errPrintln(err)
			return 1
		}

		defer rmQuar()
	}

	client := NewClient(url, key)
	if http1 {
		client.DisableHTTP2()
	}


@@ 154,19 180,67 @@ func upgradeMain(dir string, url string, threads int, http1 bool, owGameInfo boo

			defer object.Close()

			err = os.RemoveAll(path)
			if err != nil {
				return err
			}
			file, err := os.Create(path)
			if err != nil {
				return err
			}
			if checkSigs {
				sig, err := client.GetObjectSig(change.Object)
				if err != nil {
					return err
				}

				tmpPath := filepath.Join(quarentineDir, change.Object)

				file, err := os.Create(tmpPath)
				if err != nil {
					return err
				}
				defer file.Close()

				pr, pw := io.Pipe()
				tee := io.TeeReader(object, pw)

				var ferr error
				go func() {
					_, ferr = io.Copy(file, tee)
					pw.Close()
				}()

				oksig, okh, err := verifyReaderMD5(key, pr, sig, change.MD5)
				if err != nil {
					return err
				}
				if !oksig {
					return errors.New("object failed signature check: " + change.Object)
				}
				if !okh {
					return errors.New("object failed hash check: " + change.Object)
				}

				if ferr != nil {
					return ferr
				}

				err = os.RemoveAll(path)
				if err != nil {
					return err
				}

				return os.Rename(tmpPath, path)

			} else {
				err = os.RemoveAll(path)
				if err != nil {
					return err
				}

			defer file.Close()
				file, err := os.Create(path)
				if err != nil {
					return err
				}

			_, err = io.Copy(file, object)
			return err
				defer file.Close()

				_, err = io.Copy(file, object)
				return err
			}
		}

		pool.Jobch <- f

M verify.go => verify.go +1 -1
@@ 22,7 22,7 @@ func verifyMain(dir string, url string, repair bool, http1 bool, owGameInfo bool
		return 1
	}

	client := NewClient(url)
	client := NewClient(url, nil)

	if http1 {
		client.DisableHTTP2()

M versioning.go => versioning.go +5 -0
@@ 10,6 10,11 @@ const (
	TYPE_DEL
)

type Revision struct {
	Version int      `json:"version"`
	Changes []Change `json:"changes"`
}

type Change struct {
	Type   uint   `json:"type"`
	Path   string `json:"path"`