diff --git a/filesig/format_armor.go b/filesig/format_armor.go new file mode 100644 index 0000000..3806582 --- /dev/null +++ b/filesig/format_armor.go @@ -0,0 +1,123 @@ +package jess + +import ( + "bytes" + "encoding/base64" + "fmt" + "regexp" + + "github.com/safing/jess" + "github.com/safing/portbase/formats/dsd" +) + +const ( + sigFileArmorStart = "-----BEGIN JESS SIGNATURE-----" + sigFileArmorEnd = "-----END JESS SIGNATURE-----" + sigFileLineLength = 64 +) + +var ( + sigFileArmorFindMatcher = regexp.MustCompile(`(?ms)` + sigFileArmorStart + `(.+?)` + sigFileArmorEnd) + sigFileArmorRemoveMatcher = regexp.MustCompile(`(?ms)` + sigFileArmorStart + `.+?` + sigFileArmorEnd + `\r?\n?`) + whitespaceMatcher = regexp.MustCompile(`(?ms)\s`) +) + +// ParseSigFile parses a signature file and extracts any jess signatures from it. +// If signatures are returned along with an error, the error should be treated +// as a warning, but the result should also not be treated as a full success, +// as there might be missing signatures. +func ParseSigFile(fileData []byte) (signatures []*jess.Letter, err error) { + var warning error + captured := make([][]byte, 0, 1) + + // Find any signature blocks. + matches := sigFileArmorFindMatcher.FindAllSubmatch(fileData, -1) + for _, subMatches := range matches { + if len(subMatches) >= 2 { + // First entry is the whole match, second the submatch. + captured = append( + captured, + bytes.TrimPrefix( + bytes.TrimSuffix( + whitespaceMatcher.ReplaceAll(subMatches[1], nil), + []byte(sigFileArmorEnd), + ), + []byte(sigFileArmorStart), + ), + ) + } + } + + // Parse any found signatures. + signatures = make([]*jess.Letter, 0, len(captured)) + for _, sigBase64Data := range captured { + // Decode from base64 + sigData := make([]byte, base64.RawStdEncoding.DecodedLen(len(sigBase64Data))) + _, err = base64.RawStdEncoding.Decode(sigData, sigBase64Data) + if err != nil { + warning = err + continue + } + + // Parse signature. + var letter *jess.Letter + letter, err = jess.LetterFromDSD(sigData) + if err != nil { + warning = err + } else { + signatures = append(signatures, letter) + } + } + + return signatures, warning +} + +// MakeSigFileSection creates a new section for a signature file. +func MakeSigFileSection(signature *jess.Letter) ([]byte, error) { + // Serialize. + data, err := signature.ToDSD(dsd.CBOR) + if err != nil { + return nil, fmt.Errorf("failed to serialize signature: %w", err) + } + + // Encode to base64 + encodedData := make([]byte, base64.RawStdEncoding.EncodedLen(len(data))) + base64.RawStdEncoding.Encode(encodedData, data) + + // Split into lines and add armor. + splittedData := make([][]byte, 0, (len(encodedData)/sigFileLineLength)+3) + splittedData = append(splittedData, []byte(sigFileArmorStart)) + for len(encodedData) > 0 { + if len(encodedData) > sigFileLineLength { + splittedData = append(splittedData, encodedData[:sigFileLineLength]) + encodedData = encodedData[sigFileLineLength:] + } else { + splittedData = append(splittedData, encodedData) + encodedData = nil + } + } + splittedData = append(splittedData, []byte(sigFileArmorEnd)) + linedData := bytes.Join(splittedData, []byte("\n")) + + return linedData, nil +} + +// AddToSigFile adds the given signature to the signature file. +func AddToSigFile(signature *jess.Letter, sigFileData []byte, removeExistingJessSignatures bool) (newFileData []byte, err error) { + // Create new section for new sig. + newSigSection, err := MakeSigFileSection(signature) + if err != nil { + return nil, err + } + + // Remove any existing jess signature sections. + if removeExistingJessSignatures { + sigFileData = sigFileArmorRemoveMatcher.ReplaceAll(sigFileData, nil) + } + + // Append new signature section to end of file with a newline. + sigFileData = append(sigFileData, []byte("\n")...) + sigFileData = append(sigFileData, newSigSection...) + + return sigFileData, nil +} diff --git a/filesig/format_armor_test.go b/filesig/format_armor_test.go new file mode 100644 index 0000000..82928f6 --- /dev/null +++ b/filesig/format_armor_test.go @@ -0,0 +1,197 @@ +package jess + +import ( + "bytes" + "testing" + + "github.com/safing/jess" + "github.com/safing/jess/lhash" +) + +var ( + testFileSigOneKey = "7KoUBdrRfF6drrPvKianoGfEXTQFCS5wDbfQyc87VQnYApPckRS8SfrrmAXZhV1JgKfnh44ib9nydQVEDRJiZArV22RqMfPrJmQdoAsE7zuzPRSrku8yF7zfnEv46X5GsmgfdSDrFMdG7XJd3fdaxStYCXTYDS5R" + + testFileSigOneData = []byte("The quick brown fox jumps over the lazy dog") + + testFileSigOneMetaData = map[string]string{ + "id": "resource/path", + "version": "0.0.1", + } + + testFileSigOneSignature = []byte(` +-----BEGIN JESS SIGNATURE----- +Q6VnVmVyc2lvbgFnU3VpdGVJRGdzaWduX3YxZU5vbmNlRA40a/BkRGF0YVhqTYOr +TGFiZWxlZEhhc2jEIhkgAXGM7DXNPXlt0AAg4L/stHOtI0V9Bjt17/KcD/ouWKmo +U2lnbmVkQXTW/2LH/ueoTWV0YURhdGGComlkrXJlc291cmNlL3BhdGindmVyc2lv +bqUwLjAuMWpTaWduYXR1cmVzgaNmU2NoZW1lZ0VkMjU1MTliSURwZmlsZXNpZy10 +ZXN0LWtleWVWYWx1ZVhA4b1kfIJF7do6OcJnemQ5mtj/ZyMFJWWTmD1W5KvkpZac +2AP5f+dDJhzWBHsoSXTCl6uA3DA3+RbABMYAZn6eDg +-----END JESS SIGNATURE----- +`) +) + +func TestFileSigFormat(t *testing.T) { + t.Parallel() + + // Load test key. + signet, err := jess.SignetFromBase58(testFileSigOneKey) + if err != nil { + t.Fatal(err) + } + + // Store signet. + if err := testTrustStore.StoreSignet(signet); err != nil { + t.Fatal(err) + } + // Store public key for verification. + recipient, err := signet.AsRecipient() + if err != nil { + t.Fatal(err) + } + if err := testTrustStore.StoreSignet(recipient); err != nil { + t.Fatal(err) + } + + // Create envelope. + envelope := jess.NewUnconfiguredEnvelope() + envelope.SuiteID = jess.SuiteSignV1 + envelope.Senders = []*jess.Signet{signet} + + // Hash and sign file. + hash := lhash.Digest(lhash.BLAKE2b_256, testFileSigOneData) + letter, _, err := SignFileData(hash, testFileSigOneMetaData, envelope, testTrustStore) + if err != nil { + t.Fatal(err) + } + + // Serialize signature. + sigFile, err := MakeSigFileSection(letter) + if err != nil { + t.Fatal(err) + } + // fmt.Println("Signature:") + // fmt.Println(string(sigFile)) + + // Parse signature again. + sigs, err := ParseSigFile(sigFile) + if err != nil { + t.Fatal(err) + } + if len(sigs) != 1 { + t.Fatalf("one sig expected, got %d", len(sigs)) + } + + // Verify Signature. + fileData, err := VerifyFileData(sigs[0], testFileSigOneMetaData, testTrustStore) + if err != nil { + t.Fatal(err) + } + + // Verify File. + if !fileData.FileHash().MatchesData(testFileSigOneData) { + t.Fatal("file hash does not match") + } + + // Verify the saved version of the signature. + + // Parse the saved signature. + sigs, err = ParseSigFile(testFileSigOneSignature) + if err != nil { + t.Fatal(err) + } + if len(sigs) != 1 { + t.Fatalf("only one sig expected, got %d", len(sigs)) + } + + // Verify Signature. + fileData, err = VerifyFileData(sigs[0], testFileSigOneMetaData, testTrustStore) + if err != nil { + t.Fatal(err) + } + + // Verify File. + if !fileData.FileHash().MatchesData(testFileSigOneData) { + t.Fatal("file hash does not match") + } +} + +var ( + testFileSigFormat1 = []byte(`TGFiZWxlZEhhc2jEIhkgAXGM7DXNPXlt0AAg4L +-----BEGIN JESS SIGNATURE----- +Q6VnVmVyc2lvbgFnU3VpdGVJRGdzaWduX3YxZU5vbmNlRA40a/BkRGF0YVhqTYOr +TGFiZWxlZEhhc2jEIhkgAXGM7DXNPXlt0AAg4L/stHOtI0V9Bjt17/KcD/ouWKmo +U2lnbmVkQXTW/2LH/ueoTWV0YURhdGGComlkrXJlc291cmNlL3BhdGindmVyc2lv +bqUwLjAuMWpTaWduYXR1cmVzgaNmU2NoZW1lZ0VkMjU1MTliSURwZmlsZXNpZy10 +ZXN0LWtleWVWYWx1ZVhA4b1kfIJF7do6OcJnemQ5mtj/ZyMFJWWTmD1W5KvkpZac +2AP5f+dDJhzWBHsoSXTCl6uA3DA3+RbABMYAZn6eDg +-----END JESS SIGNATURE----- + +-----END JESS SIGNATURE----- +-----BEGIN JESS SIGNATURE----- +Q6VnVmVyc2lvbgFnU3VpdGVJRGdzaWduX3YxZU5vbmNlRA40a/BkRGF0YVhqTYOr + TGFiZWxlZEhhc2jEIhkgAXGM7DXNPXlt0AAg4L/stHOtI0V9Bjt17/KcD/ouWKmo + U2lnbmVkQXTW/2LH/ueoTWV0YURhdGGComlk +rXJlc291cmNlL3BhdGindmVyc2lvbqUwLjAuMWpTaWduYXR1cmVzgaNmU2NoZW1lZ0VkMjU1MTliSURwZmlsZXNpZy10 + ZXN0LWtleWVWYWx1ZVhA4b1kfIJF7do6OcJnemQ5mtj/ZyMFJWWTmD1W5KvkpZac +2AP5f+dDJhzWBHsoSXTCl6uA3DA3+RbABMYAZn6eDg +-----END JESS SIGNATURE----- +end`) + + testFileSigFormat2 = []byte(`test data 1 +-----BEGIN JESS SIGNATURE----- +invalid sig +-----END JESS SIGNATURE----- +test data 2`) + + testFileSigFormat3 = []byte(`test data 1 +-----BEGIN JESS SIGNATURE----- +invalid sig +-----END JESS SIGNATURE----- +test data 2 +-----BEGIN JESS SIGNATURE----- +Q6VnVmVyc2lvbgFnU3VpdGVJRGdzaWduX3YxZU5vbmNlRA40a/BkRGF0YVhqTYOr +TGFiZWxlZEhhc2jEIhkgAXGM7DXNPXlt0AAg4L/stHOtI0V9Bjt17/KcD/ouWKmo +U2lnbmVkQXTW/2LH/ueoTWV0YURhdGGComlkrXJlc291cmNlL3BhdGindmVyc2lv +bqUwLjAuMWpTaWduYXR1cmVzgaNmU2NoZW1lZ0VkMjU1MTliSURwZmlsZXNpZy10 +ZXN0LWtleWVWYWx1ZVhA4b1kfIJF7do6OcJnemQ5mtj/ZyMFJWWTmD1W5KvkpZac +2AP5f+dDJhzWBHsoSXTCl6uA3DA3+RbABMYAZn6eDg +-----END JESS SIGNATURE-----`) + + testFileSigFormat4 = []byte(`test data 1 +test data 2 +-----BEGIN JESS SIGNATURE----- +Q6VnVmVyc2lvbgFnU3VpdGVJRGdzaWduX3YxZU5vbmNlRA40a/BkRGF0YVhqTYOr +TGFiZWxlZEhhc2jEIhkgAXGM7DXNPXlt0AAg4L/stHOtI0V9Bjt17/KcD/ouWKmo +U2lnbmVkQXTW/2LH/ueoTWV0YURhdGGComlkrXJlc291cmNlL3BhdGindmVyc2lv +bqUwLjAuMWpTaWduYXR1cmVzgaNmU2NoZW1lZ0VkMjU1MTliSURwZmlsZXNpZy10 +ZXN0LWtleWVWYWx1ZVhA4b1kfIJF7do6OcJnemQ5mtj/ZyMFJWWTmD1W5KvkpZac +2AP5f+dDJhzWBHsoSXTCl6uA3DA3+RbABMYAZn6eDg +-----END JESS SIGNATURE-----`) +) + +func TestFileSigFormatParsing(t *testing.T) { + t.Parallel() + + sigs, err := ParseSigFile(testFileSigFormat1) + if err != nil { + t.Fatal(err) + } + if len(sigs) != 2 { + t.Fatalf("expected two signatures, got %d", 1) + } + + newFile, err := AddToSigFile(sigs[0], testFileSigFormat2, false) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(newFile, testFileSigFormat3) { + t.Fatalf("unexpected output:\n%s", string(newFile)) + } + newFile, err = AddToSigFile(sigs[0], testFileSigFormat2, true) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(newFile, testFileSigFormat4) { + t.Fatalf("unexpected output:\n%s", string(newFile)) + } +} diff --git a/filesig/helpers.go b/filesig/helpers.go new file mode 100644 index 0000000..55d0989 --- /dev/null +++ b/filesig/helpers.go @@ -0,0 +1,119 @@ +package jess + +import ( + "errors" + "fmt" + "io/ioutil" + "strings" + + "github.com/safing/jess" + "github.com/safing/jess/hashtools" +) + +// SignFile signs a file and replaces the signature file with a new one. +func SignFile(dataFilePath, signatureFilePath string, metaData map[string]string, envelope *jess.Envelope, trustStore jess.TrustStore) (fileData *FileData, err error) { + // Load encryption suite. + if err := envelope.LoadSuite(); err != nil { + return nil, err + } + + // Extract the used hashing algorithm from the suite. + var hashTool *hashtools.HashTool + for _, toolID := range envelope.Suite().Tools { + if strings.Contains(toolID, "(") { + hashToolID := strings.Trim(strings.Split(toolID, "(")[0], "()") + hashTool, _ = hashtools.Get(hashToolID) + break + } + } + if hashTool == nil { + return nil, errors.New("suite not suitable for file signing") + } + + // Hash the data file. + fileHash, err := hashTool.LabeledHasher().DigestFile(dataFilePath) + if err != nil { + return nil, fmt.Errorf("failed to hash file: %w", err) + } + + // Sign the file data. + signature, fileData, err := SignFileData(fileHash, metaData, envelope, trustStore) + if err != nil { + return nil, fmt.Errorf("failed to sign file: %w", err) + } + + // Make signature section for saving to disk. + signatureSection, err := MakeSigFileSection(signature) + if err != nil { + return nil, fmt.Errorf("failed to format signature for file: %w", err) + } + + // Write the signature to file. + if err := ioutil.WriteFile(signatureFilePath, signatureSection, 0o0644); err != nil { //nolint:gosec + return nil, fmt.Errorf("failed to write signature to file: %w", err) + } + + return fileData, nil +} + +// VerifyFile verifies the given files and returns the verified file data. +// If an error is returned, there was an error in at least some part of the process. +// Any returned file data struct must be checked for an verification error. +func VerifyFile(dataFilePath, signatureFilePath string, metaData map[string]string, trustStore jess.TrustStore) (verifiedFileData []*FileData, err error) { + var lastErr error + + // Read signature from file. + sigFileData, err := ioutil.ReadFile(signatureFilePath) + if err != nil { + return nil, fmt.Errorf("failed to read signature file: %w", err) + } + + // Extract all signatures. + sigs, err := ParseSigFile(sigFileData) + switch { + case len(sigs) == 0 && err != nil: + return nil, fmt.Errorf("failed to parse signature file: %w", err) + case len(sigs) == 0: + return nil, errors.New("no signatures found in signature file") + case err != nil: + lastErr = fmt.Errorf("failed to parse signature file: %w", err) + } + + // Verify all signatures. + goodFileData := make([]*FileData, 0, len(sigs)) + var badFileData []*FileData + for _, sigLetter := range sigs { + // Verify signature. + fileData, err := VerifyFileData(sigLetter, metaData, trustStore) + if err != nil { + lastErr = err + if fileData != nil { + fileData.verificationError = err + badFileData = append(badFileData, fileData) + } + continue + } + + // Hash the file. + fileHash, err := fileData.FileHash().Algorithm().DigestFile(dataFilePath) + if err != nil { + lastErr = err + fileData.verificationError = err + badFileData = append(badFileData, fileData) + continue + } + + // Check if the hash matches. + if !fileData.FileHash().Equal(fileHash) { + lastErr = errors.New("signature invalid: file was modified") + fileData.verificationError = lastErr + badFileData = append(badFileData, fileData) + continue + } + + // Add verified file data to list for return. + goodFileData = append(goodFileData, fileData) + } + + return append(goodFileData, badFileData...), lastErr +} diff --git a/filesig/main.go b/filesig/main.go new file mode 100644 index 0000000..b8376e4 --- /dev/null +++ b/filesig/main.go @@ -0,0 +1,112 @@ +package jess + +import ( + "fmt" + "time" + + "github.com/safing/jess" + "github.com/safing/jess/lhash" + "github.com/safing/portbase/formats/dsd" +) + +var fileSigRequirements = jess.NewRequirements(). + Remove(jess.RecipientAuthentication). + Remove(jess.Confidentiality) + +// FileData describes a file that is signed. +type FileData struct { + LabeledHash []byte + fileHash *lhash.LabeledHash + + SignedAt time.Time + MetaData map[string]string + + verificationError error +} + +// FileHash returns the labeled hash of the file that was signed. +func (fd *FileData) FileHash() *lhash.LabeledHash { + return fd.fileHash +} + +// VerificationError returns the error encountered during verification. +func (fd *FileData) VerificationError() error { + return fd.verificationError +} + +// SignFileData signs the given file checksum and metadata. +func SignFileData(fileHash *lhash.LabeledHash, metaData map[string]string, envelope *jess.Envelope, trustStore jess.TrustStore) (letter *jess.Letter, fd *FileData, err error) { + // Create session. + session, err := envelope.Correspondence(trustStore) + if err != nil { + return nil, nil, err + } + + // Check if the envelope is suitable for signing. + if err := envelope.Suite().Provides.CheckComplianceTo(fileSigRequirements); err != nil { + return nil, nil, fmt.Errorf("envelope not suitable for signing") + } + + // Create struct and transform data into serializable format to be signed. + fd = &FileData{ + SignedAt: time.Now().Truncate(time.Second), + fileHash: fileHash, + MetaData: metaData, + } + fd.LabeledHash = fd.fileHash.Bytes() + + // Serialize file signature. + fileData, err := dsd.Dump(fd, dsd.MsgPack) + if err != nil { + return nil, nil, fmt.Errorf("failed to serialize file signature data: %w", err) + } + + // Sign data. + letter, err = session.Close(fileData) + if err != nil { + return nil, nil, fmt.Errorf("failed to sign: %w", err) + } + + return letter, fd, nil +} + +// VerifyFileData verifies the given signed file data and returns the file data. +// If an error is returned, there was an error in at least some part of the process. +// Any returned file data struct must be checked for an verification error. +func VerifyFileData(letter *jess.Letter, requiredMetaData map[string]string, trustStore jess.TrustStore) (fd *FileData, err error) { + // Parse data. + fd = &FileData{} + _, err = dsd.Load(letter.Data, fd) + if err != nil { + return nil, fmt.Errorf("failed to parse file signature data: %w", err) + } + + // Verify signature and get data. + _, err = letter.Open(fileSigRequirements, trustStore) + if err != nil { + fd.verificationError = fmt.Errorf("failed to verify file signature: %w", err) + return fd, fd.verificationError + } + + // Check if the required metadata matches. + for reqKey, reqValue := range requiredMetaData { + sigMetaValue, ok := fd.MetaData[reqKey] + if !ok { + fd.verificationError = fmt.Errorf("missing required metadata key %q", reqKey) + return fd, fd.verificationError + } + if sigMetaValue != reqValue { + fd.verificationError = fmt.Errorf("required metadata %q=%q does not match the file's value %q", reqKey, reqValue, sigMetaValue) + return fd, fd.verificationError + } + } + + // Parse labeled hash. + fd.fileHash, err = lhash.Load(fd.LabeledHash) + if err != nil { + fd.verificationError = fmt.Errorf("failed to parse file checksum: %w", err) + return fd, fd.verificationError + } + + return fd, nil +} diff --git a/filesig/main_test.go b/filesig/main_test.go new file mode 100644 index 0000000..c6920c4 --- /dev/null +++ b/filesig/main_test.go @@ -0,0 +1,130 @@ +package jess + +import ( + "errors" + "testing" + "time" + + "github.com/safing/jess" + "github.com/safing/jess/lhash" + "github.com/safing/jess/tools" +) + +var ( + testTrustStore = jess.NewMemTrustStore() + testData1 = "The quick brown fox jumps over the lazy dog. " + + testFileSigMetaData1 = map[string]string{ + "key1": "value1", + "key2": "value2", + } + testFileSigMetaData1x = map[string]string{ + "key1": "value1x", + } + testFileSigMetaData2 = map[string]string{ + "key3": "value3", + "key4": "value4", + } + testFileSigMetaData3 = map[string]string{} +) + +func TestFileSigs(t *testing.T) { + t.Parallel() + + testFileSigningWithOptions(t, testFileSigMetaData1, testFileSigMetaData1, true) + testFileSigningWithOptions(t, testFileSigMetaData1, testFileSigMetaData1x, false) + testFileSigningWithOptions(t, testFileSigMetaData2, testFileSigMetaData2, true) + testFileSigningWithOptions(t, testFileSigMetaData1, testFileSigMetaData2, false) + testFileSigningWithOptions(t, testFileSigMetaData2, testFileSigMetaData1, false) + testFileSigningWithOptions(t, testFileSigMetaData1, testFileSigMetaData3, true) + testFileSigningWithOptions(t, testFileSigMetaData3, testFileSigMetaData1, false) +} + +func testFileSigningWithOptions(t *testing.T, signingMetaData, verifyingMetaData map[string]string, shouldSucceed bool) { + t.Helper() + + // Get tool for key generation. + tool, err := tools.Get("Ed25519") + if err != nil { + t.Fatal(err) + } + + // Generate key pair. + s, err := getOrMakeSignet(t, tool.StaticLogic, false, "test-key-filesig-1") + if err != nil { + t.Fatal(err) + } + + // Hash "file". + fileHash := lhash.BLAKE2b_256.Digest([]byte(testData1)) + + // Make envelope. + envelope := jess.NewUnconfiguredEnvelope() + envelope.SuiteID = jess.SuiteSignV1 + envelope.Senders = []*jess.Signet{s} + + // Sign data. + letter, fileData, err := SignFileData(fileHash, signingMetaData, envelope, testTrustStore) + if err != nil { + t.Fatal(err) + } + + // Check if the checksum made it. + if len(fileData.LabeledHash) == 0 { + t.Fatal("missing labeled hash") + } + + // Verify signature. + _, err = VerifyFileData(letter, verifyingMetaData, testTrustStore) + if (err == nil) != shouldSucceed { + t.Fatal(err) + } +} + +func getOrMakeSignet(t *testing.T, tool tools.ToolLogic, recipient bool, signetID string) (*jess.Signet, error) { + t.Helper() + + // check if signet already exists + signet, err := testTrustStore.GetSignet(signetID, recipient) + if err == nil { + return signet, nil + } + + // handle special cases + if tool == nil { + return nil, errors.New("bad parameters") + } + + // create new signet + newSignet := jess.NewSignetBase(tool.Definition()) + newSignet.ID = signetID + // generate signet and log time taken + start := time.Now() + err = tool.GenerateKey(newSignet) + if err != nil { + return nil, err + } + t.Logf("generated %s signet %s in %s", newSignet.Scheme, newSignet.ID, time.Since(start)) + + // store signet + err = testTrustStore.StoreSignet(newSignet) + if err != nil { + return nil, err + } + + // store recipient + newRcpt, err := newSignet.AsRecipient() + if err != nil { + return nil, err + } + err = testTrustStore.StoreSignet(newRcpt) + if err != nil { + return nil, err + } + + // return + if recipient { + return newRcpt, nil + } + return newSignet, nil +}