From 74d41194cc61044571a600775e9d68c0f8d358a6 Mon Sep 17 00:00:00 2001
From: Daniel <dhaavi@users.noreply.github.com>
Date: Mon, 11 Jul 2022 17:04:48 +0200
Subject: [PATCH] Add support for file signatures in cli

---
 cmd/cmd-close.go  |   6 +-
 cmd/cmd-open.go   |   5 +
 cmd/cmd-verify.go | 271 ++++++++++++++++++++++++++++++++++++++--------
 cmd/format_sig.go |  79 ++++++++++++++
 cmd/main.go       |   7 +-
 5 files changed, 315 insertions(+), 53 deletions(-)
 create mode 100644 cmd/format_sig.go

diff --git a/cmd/cmd-close.go b/cmd/cmd-close.go
index bec65bb..62e7e2f 100644
--- a/cmd/cmd-close.go
+++ b/cmd/cmd-close.go
@@ -12,7 +12,7 @@ import (
 
 func init() {
 	rootCmd.AddCommand(closeCmd)
-	closeCmd.Flags().StringVarP(&closeFlagOutput, "output", "o", "", "specify output file (`-` for stdout")
+	closeCmd.Flags().StringVarP(&closeFlagOutput, "output", "o", "", "specify output file (`-` for stdout)")
 }
 
 var (
@@ -49,10 +49,10 @@ var (
 			filename := args[0]
 			outputFilename := closeFlagOutput
 			if outputFilename == "" {
-				if strings.HasSuffix(filename, ".letter") {
+				if strings.HasSuffix(filename, letterFileExtension) {
 					return errors.New("cannot automatically derive output filename, please specify with --output")
 				}
-				outputFilename = filename + ".letter"
+				outputFilename = filename + letterFileExtension
 			}
 			// check input file
 			if filename != "-" {
diff --git a/cmd/cmd-open.go b/cmd/cmd-open.go
index b8a34e9..edf3ecf 100644
--- a/cmd/cmd-open.go
+++ b/cmd/cmd-open.go
@@ -93,6 +93,11 @@ var (
 				return err
 			}
 
+			// Create default requirements if not set.
+			if requirements == nil {
+				requirements = jess.NewRequirements()
+			}
+
 			// decrypt (and verify)
 			plainText, err := letter.Open(requirements, trustStore)
 			if err != nil {
diff --git a/cmd/cmd-verify.go b/cmd/cmd-verify.go
index 6e36ad5..1efc832 100644
--- a/cmd/cmd-verify.go
+++ b/cmd/cmd-verify.go
@@ -3,85 +3,260 @@ package main
 import (
 	"errors"
 	"fmt"
+	"io/fs"
 	"io/ioutil"
 	"os"
+	"path/filepath"
+	"strings"
 
 	"github.com/spf13/cobra"
 
 	"github.com/safing/jess"
+	"github.com/safing/jess/filesig"
 	"github.com/safing/portbase/container"
 )
 
 func init() {
 	rootCmd.AddCommand(verifyCmd)
+	verifyCmd.Flags().StringToStringVarP(&metaDataFlag, "metadata", "m", nil, "specify file metadata to verify (.sig only)")
 }
 
-var (
-	verifyCmdHelp = "usage: jess verify <file>"
+var verifyCmd = &cobra.Command{
+	Use:                   "verify <files and directories>",
+	Short:                 "verify signed files and files in directories",
+	DisableFlagsInUseLine: true,
+	Args:                  cobra.MinimumNArgs(1),
+	PreRunE:               requireTrustStore,
+	RunE: func(cmd *cobra.Command, args []string) (err error) {
+		var verificationFails, verificationWarnings int
 
-	verifyCmd = &cobra.Command{
-		Use:                   "verify <file>",
-		Short:                 "verify file",
-		DisableFlagsInUseLine: true,
-		RunE: func(cmd *cobra.Command, args []string) (err error) {
-			// check args
-			if len(args) != 1 {
-				return errors.New(verifyCmdHelp)
+		// Check if we are only verifying a single file.
+		if len(args) == 1 {
+			matches, err := filepath.Glob(args[0])
+			if err != nil {
+				return err
 			}
 
-			// check filenames
-			filename := args[0]
-			// check input file
-			if filename != "-" {
-				fileInfo, err := os.Stat(filename)
+			switch len(matches) {
+			case 0:
+				return errors.New("file not found")
+			case 1:
+				// Check if the single match is a file.
+				fileInfo, err := os.Stat(matches[0])
 				if err != nil {
 					return err
 				}
-				if fileInfo.Size() > warnFileSize {
-					confirmed, err := confirm("Input file is really big (%s) and jess needs to load it fully to memory, continue?", true)
-					if err != nil {
-						return err
-					}
-					if !confirmed {
-						return nil
-					}
+				// Verify file if it is not a directory.
+				if !fileInfo.IsDir() {
+					return verify(matches[0], false)
 				}
 			}
+		}
 
-			// load file
-			var data []byte
-			if filename == "-" {
-				data, err = ioutil.ReadAll(os.Stdin)
-			} else {
-				data, err = ioutil.ReadFile(filename)
-			}
+		// Resolve globs.
+		files := make([]string, 0, len(args))
+		for _, arg := range args {
+			matches, err := filepath.Glob(arg)
 			if err != nil {
 				return err
 			}
+			files = append(files, matches...)
+		}
 
-			// parse file
-			letter, err := jess.LetterFromFileFormat(container.New(data))
+		// Go through all files.
+		for _, file := range files {
+			fileInfo, err := os.Stat(file)
 			if err != nil {
-				return err
+				verificationWarnings++
+				fmt.Printf("[WARN] %s failed to read: %s\n", file, err)
+				continue
 			}
 
-			// adjust requirements
-			if requirements == nil {
-				requirements = jess.NewRequirements().
-					Remove(jess.Confidentiality).
-					Remove(jess.Integrity).
-					Remove(jess.RecipientAuthentication)
+			// Walk directories.
+			if fileInfo.IsDir() {
+				err := filepath.Walk(file, func(path string, info fs.FileInfo, err error) error {
+					// Log walking errors.
+					if err != nil {
+						verificationWarnings++
+						fmt.Printf("[WARN] %s failed to read: %s\n", path, err)
+						return nil
+					}
+
+					// Only verify if .sig or .letter.
+					if strings.HasSuffix(path, sigFileExtension) ||
+						strings.HasSuffix(path, letterFileExtension) {
+						if err := verify(path, true); err != nil {
+							verificationFails++
+						}
+					}
+					return nil
+				})
+				if err != nil {
+					verificationWarnings++
+					fmt.Printf("[WARN] %s failed to walk directory: %s\n", file, err)
+				}
+				continue
 			}
 
-			// verify
-			err = letter.Verify(requirements, trustStore)
-			if err != nil {
-				return err
+			if err := verify(file, true); err != nil {
+				verificationFails++
 			}
+		}
 
-			// success
-			fmt.Println("ok")
-			return nil
-		},
+		// End with error status if any verification failed.
+		if verificationFails > 0 {
+			return fmt.Errorf("%d verification failures", verificationFails)
+		}
+		if verificationWarnings > 0 {
+			return fmt.Errorf("%d warnings", verificationWarnings)
+		}
+
+		return nil
+	},
+}
+
+var verifiedSigs = make(map[string]struct{})
+
+func verify(filename string, bulkMode bool) error {
+	// Check if file was already verified.
+	if _, alreadyVerified := verifiedSigs[filename]; alreadyVerified {
+		return nil
 	}
-)
+
+	var (
+		signame  string
+		signedBy []string
+		err      error
+	)
+
+	// Get correct files and verify.
+	switch {
+	case filename == stdInOutFilename:
+		signedBy, err = verifyLetter(filename, bulkMode)
+	case strings.HasSuffix(filename, letterFileExtension):
+		signedBy, err = verifyLetter(filename, bulkMode)
+	case strings.HasSuffix(filename, sigFileExtension):
+		filename = strings.TrimSuffix(filename, sigFileExtension)
+		fallthrough
+	default:
+		signame = filename + sigFileExtension
+		signedBy, err = verifySig(filename, signame, bulkMode)
+	}
+
+	// Remember the files already verified.
+	verifiedSigs[filename] = struct{}{}
+	if signame != "" {
+		verifiedSigs[signame] = struct{}{}
+	}
+
+	// Output result in bulk mode.
+	if bulkMode {
+		if err == nil {
+			fmt.Printf("[ OK ] %s signed by %s\n", filename, strings.Join(signedBy, ", "))
+		} else {
+			fmt.Printf("[FAIL] %s failed to verify: %s\n", filename, err)
+		}
+	}
+
+	return err
+}
+
+func verifyLetter(filename string, silent bool) (signedBy []string, err error) {
+	if len(metaDataFlag) > 0 {
+		return nil, errors.New("metadata flag only valid for verifying .sig files")
+	}
+
+	if filename != "-" {
+		fileInfo, err := os.Stat(filename)
+		if err != nil {
+			return nil, err
+		}
+		if fileInfo.Size() > warnFileSize {
+			confirmed, err := confirm("Input file is really big (%s) and jess needs to load it fully to memory, continue?", true)
+			if err != nil {
+				return nil, err
+			}
+			if !confirmed {
+				return nil, nil
+			}
+		}
+	}
+
+	// load file
+	var data []byte
+	if filename == "-" {
+		data, err = ioutil.ReadAll(os.Stdin)
+	} else {
+		data, err = ioutil.ReadFile(filename)
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	// parse file
+	letter, err := jess.LetterFromFileFormat(container.New(data))
+	if err != nil {
+		return nil, err
+	}
+
+	// Create default requirements if not set.
+	if requirements == nil {
+		requirements = jess.NewRequirements().
+			Remove(jess.Confidentiality).
+			Remove(jess.RecipientAuthentication)
+	}
+
+	// verify
+	err = letter.Verify(requirements, trustStore)
+	if err != nil {
+		return nil, err
+	}
+
+	// get signers
+	signedBy = make([]string, 0, len(letter.Signatures))
+	for _, seal := range letter.Signatures {
+		if signet, err := trustStore.GetSignet(seal.ID, true); err == nil {
+			signedBy = append(signedBy, fmt.Sprintf("%s (%s)", signet.Info.Name, seal.ID))
+		} else {
+			signedBy = append(signedBy, seal.ID)
+		}
+	}
+
+	// success
+	if !silent {
+		if err == nil {
+			fmt.Println("Verification: OK")
+			fmt.Printf("Signed By: %s\n", strings.Join(signedBy, ", "))
+		} else {
+			fmt.Printf("Verification FAILED: %s\n\n", err)
+		}
+	}
+
+	return signedBy, nil
+}
+
+func verifySig(filename, signame string, silent bool) (signedBy []string, err error) {
+	fds, err := filesig.VerifyFile(filename, signame, metaDataFlag, trustStore)
+	if err != nil {
+		return nil, err
+	}
+
+	if !silent {
+		fmt.Print(formatSignatures(filename, signame, fds))
+		return nil, nil
+	}
+
+	signedBy = make([]string, 0, len(fds))
+	for _, fd := range fds {
+		if sig := fd.Signature(); sig != nil {
+			for _, seal := range sig.Signatures {
+				if signet, err := trustStore.GetSignet(seal.ID, true); err == nil {
+					signedBy = append(signedBy, fmt.Sprintf("%s (%s)", signet.Info.Name, seal.ID))
+				} else {
+					signedBy = append(signedBy, seal.ID)
+				}
+			}
+		}
+	}
+	return signedBy, nil
+}
diff --git a/cmd/format_sig.go b/cmd/format_sig.go
new file mode 100644
index 0000000..1970b2e
--- /dev/null
+++ b/cmd/format_sig.go
@@ -0,0 +1,79 @@
+package main
+
+import (
+	"encoding/hex"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/safing/jess/filesig"
+)
+
+func formatSignatures(filename, signame string, fds []*filesig.FileData) string {
+	b := &strings.Builder{}
+
+	switch len(fds) {
+	case 0:
+	case 1:
+		formatSignature(b, fds[0])
+	case 2:
+		for _, fd := range fds {
+			fmt.Fprintf(b, "%d Signatures:\n\n\n", len(fds))
+			formatSignature(b, fd)
+			b.WriteString("\n\n")
+		}
+	}
+
+	if filename != "" || signame != "" {
+		b.WriteString("\n")
+		fmt.Fprintf(b, "File: %s\n", filename)
+		fmt.Fprintf(b, "Sig:  %s\n", signame)
+	}
+
+	return b.String()
+}
+
+func formatSignature(b *strings.Builder, fd *filesig.FileData) {
+	if fd.VerificationError() == nil {
+		b.WriteString("Verification: OK\n")
+	} else {
+		fmt.Fprintf(b, "Verification FAILED: %s\n", fd.VerificationError())
+	}
+
+	if letter := fd.Signature(); letter != nil {
+		b.WriteString("\n")
+		for _, sig := range letter.Signatures {
+			signet, err := trustStore.GetSignet(sig.ID, true)
+			if err == nil {
+				fmt.Fprintf(b, "Signed By: %s (%s)\n", signet.Info.Name, sig.ID)
+			} else {
+				fmt.Fprintf(b, "Signed By: %s\n", sig.ID)
+			}
+		}
+	}
+
+	if fileHash := fd.FileHash(); fileHash != nil {
+		b.WriteString("\n")
+		fmt.Fprintf(b, "Hash Alg: %s\n", fileHash.Algorithm())
+		fmt.Fprintf(b, "Hash Sum: %s\n", hex.EncodeToString(fileHash.Sum()))
+	}
+
+	if len(fd.MetaData) > 0 {
+		b.WriteString("\nMetadata:\n")
+
+		sortedMetaData := make([][]string, 0, len(fd.MetaData))
+		for k, v := range fd.MetaData {
+			sortedMetaData = append(sortedMetaData, []string{k, v})
+		}
+		sort.Sort(sortByMetaDataKey(sortedMetaData))
+		for _, entry := range sortedMetaData {
+			fmt.Fprintf(b, "    %s: %s\n", entry[0], entry[1])
+		}
+	}
+}
+
+type sortByMetaDataKey [][]string
+
+func (a sortByMetaDataKey) Len() int           { return len(a) }
+func (a sortByMetaDataKey) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
+func (a sortByMetaDataKey) Less(i, j int) bool { return a[i][0] < a[j][0] }
diff --git a/cmd/main.go b/cmd/main.go
index 2027948..93ad9d5 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -14,6 +14,10 @@ import (
 )
 
 const (
+	stdInOutFilename    = "-"
+	letterFileExtension = ".letter"
+	sigFileExtension    = ".sig"
+
 	warnFileSize = 12000000 // 120MB
 )
 
@@ -33,7 +37,7 @@ var (
 	defaultSymmetricKeySize = 0
 
 	trustStore   truststores.ExtendedTrustStore
-	requirements = jess.NewRequirements()
+	requirements *jess.Requirements
 )
 
 func main() {
@@ -74,7 +78,6 @@ func initGlobalFlags(cmd *cobra.Command, args []string) (err error) {
 			return err
 		}
 	}
-
 	// requirements
 	if noSpec != "" {
 		requirements, err = jess.ParseRequirementsFromNoSpec(noSpec)