From d4d06574b80a874fca0f2eaf2f050fead33b401b Mon Sep 17 00:00:00 2001
From: Daniel <dhaavi@users.noreply.github.com>
Date: Mon, 8 Aug 2022 14:45:06 +0200
Subject: [PATCH] Add text format for signets and envelopes and support for
 import/export

---
 cmd/cfg-envelope.go                           |  28 ++-
 ...c84c-78f7-4354-a7f5-0e115aa2903c.recipient |  14 ++
 ...911c84c-78f7-4354-a7f5-0e115aa2903c.signet |  13 ++
 .../.truststore/safing-codesign-1.envelope    |  23 +++
 cmd/testdata/test.txt                         |   1 +
 cmd/testdata/test.txt.letter                  |  13 ++
 cmd/testdata/test.txt.sig                     |   9 +
 cmd/testdata/test3.txt                        |   1 +
 cmd/testdata/test3.txt.sig                    |   9 +
 cmd/testdata/test4.txt                        |   1 +
 cmd/testdata/testdir/test2.txt                |   1 +
 cmd/testdata/testdir/test2.txt.sig            |   9 +
 envelope.go                                   |  81 ++++++++
 import_export.go                              | 192 ++++++++++++++++++
 signet.go                                     |   8 +
 15 files changed, 393 insertions(+), 10 deletions(-)
 create mode 100644 cmd/testdata/.truststore/3911c84c-78f7-4354-a7f5-0e115aa2903c.recipient
 create mode 100644 cmd/testdata/.truststore/3911c84c-78f7-4354-a7f5-0e115aa2903c.signet
 create mode 100644 cmd/testdata/.truststore/safing-codesign-1.envelope
 create mode 100644 cmd/testdata/test.txt
 create mode 100644 cmd/testdata/test.txt.letter
 create mode 100644 cmd/testdata/test.txt.sig
 create mode 100644 cmd/testdata/test3.txt
 create mode 100644 cmd/testdata/test3.txt.sig
 create mode 100644 cmd/testdata/test4.txt
 create mode 100644 cmd/testdata/testdir/test2.txt
 create mode 100644 cmd/testdata/testdir/test2.txt.sig
 create mode 100644 import_export.go

diff --git a/cmd/cfg-envelope.go b/cmd/cfg-envelope.go
index 8a58686..873ac6b 100644
--- a/cmd/cfg-envelope.go
+++ b/cmd/cfg-envelope.go
@@ -30,7 +30,7 @@ func newEnvelope(name string) (*jess.Envelope, error) {
 			"Encrypt with key",
 			"Encrypt for someone and sign",
 			"Encrypt for someone but don't sign",
-			"Sign a file",
+			"Sign a file (wrapped)",
 		},
 	}
 	err := survey.AskOne(prompt, &preset, nil)
@@ -93,6 +93,7 @@ func editEnvelope(envelope *jess.Envelope) error {
 				{"Recipients", formatSignetNames(envelope.Recipients)},
 				{"Senders", formatSignetNames(envelope.Senders)},
 				{""},
+				{"Export", "export to text format"},
 				{"Abort", "discard changes and return"},
 				{"Delete", "delete and return"},
 			}),
@@ -105,21 +106,28 @@ func editEnvelope(envelope *jess.Envelope) error {
 
 		switch {
 		case strings.HasPrefix(submenu, "Done"):
-			// Check if the envolope is valid.
+			// Check if the envelope is valid.
 			if envelope.SecurityLevel == 0 {
 				fmt.Println("Envelope is invalid, please fix before saving.")
 				continue
 			}
 			// Remove and keys and save.
-			_ = envelope.LoopSecrets("", func(signet *jess.Signet) error {
-				signet.Key = nil
-				return nil
-			})
-			_ = envelope.LoopSenders("", func(signet *jess.Signet) error {
-				signet.Key = nil
-				return nil
-			})
+			envelope.CleanSignets()
 			return trustStore.StoreEnvelope(envelope)
+		case strings.HasPrefix(submenu, "Export"):
+			// Check if the envelope is valid.
+			if envelope.SecurityLevel == 0 {
+				fmt.Println("Envelope is invalid, please fix before exporting.")
+				continue
+			}
+			// Remove keys and export.
+			envelope.CleanSignets()
+			text, err := envelope.Export(false)
+			if err != nil {
+				return fmt.Errorf("failed to export: %w", err)
+			}
+			fmt.Println("Exported envelope:")
+			fmt.Println(text)
 		case strings.HasPrefix(submenu, "Abort"):
 			return nil
 		case strings.HasPrefix(submenu, "Delete"):
diff --git a/cmd/testdata/.truststore/3911c84c-78f7-4354-a7f5-0e115aa2903c.recipient b/cmd/testdata/.truststore/3911c84c-78f7-4354-a7f5-0e115aa2903c.recipient
new file mode 100644
index 0000000..1958281
--- /dev/null
+++ b/cmd/testdata/.truststore/3911c84c-78f7-4354-a7f5-0e115aa2903c.recipient
@@ -0,0 +1,14 @@
+J{
+	"Version": 1,
+	"ID": "3911c84c-78f7-4354-a7f5-0e115aa2903c",
+	"Scheme": "Ed25519",
+	"Key": "ATYVZjmhR1Zwe0KAPV99pzbzI+6zWgKvELNhFwolRdnv",
+	"Public": true,
+	"Info": {
+		"Name": "Safing Code Signing Cert 1",
+		"Owner": "",
+		"Created": "2022-07-11T10:23:31.705715613+02:00",
+		"Expires": "0001-01-01T00:00:00Z",
+		"Details": null
+	}
+}
\ No newline at end of file
diff --git a/cmd/testdata/.truststore/3911c84c-78f7-4354-a7f5-0e115aa2903c.signet b/cmd/testdata/.truststore/3911c84c-78f7-4354-a7f5-0e115aa2903c.signet
new file mode 100644
index 0000000..ef9764d
--- /dev/null
+++ b/cmd/testdata/.truststore/3911c84c-78f7-4354-a7f5-0e115aa2903c.signet
@@ -0,0 +1,13 @@
+J{
+	"Version": 1,
+	"ID": "3911c84c-78f7-4354-a7f5-0e115aa2903c",
+	"Scheme": "Ed25519",
+	"Key": "Aee5n/V1wJM8aNpaNEPBEPeN6S0Tl41OJP0rHwtsGcZcNhVmOaFHVnB7QoA9X32nNvMj7rNaAq8Qs2EXCiVF2e8=",
+	"Info": {
+		"Name": "Safing Code Signing Cert 1",
+		"Owner": "",
+		"Created": "2022-07-11T10:23:31.705715613+02:00",
+		"Expires": "0001-01-01T00:00:00Z",
+		"Details": null
+	}
+}
\ No newline at end of file
diff --git a/cmd/testdata/.truststore/safing-codesign-1.envelope b/cmd/testdata/.truststore/safing-codesign-1.envelope
new file mode 100644
index 0000000..d853b67
--- /dev/null
+++ b/cmd/testdata/.truststore/safing-codesign-1.envelope
@@ -0,0 +1,23 @@
+J{
+	"Version": 1,
+	"Name": "safing-codesign-1",
+	"SuiteID": "signfile_v1",
+	"Secrets": null,
+	"Senders": [
+		{
+			"Version": 1,
+			"ID": "3911c84c-78f7-4354-a7f5-0e115aa2903c",
+			"Scheme": "Ed25519",
+			"Key": null,
+			"Info": {
+				"Name": "Safing Code Signing Cert 1",
+				"Owner": "",
+				"Created": "2022-07-11T10:23:31.705715613+02:00",
+				"Expires": "0001-01-01T00:00:00Z",
+				"Details": null
+			}
+		}
+	],
+	"Recipients": null,
+	"SecurityLevel": 128
+}
\ No newline at end of file
diff --git a/cmd/testdata/test.txt b/cmd/testdata/test.txt
new file mode 100644
index 0000000..a042389
--- /dev/null
+++ b/cmd/testdata/test.txt
@@ -0,0 +1 @@
+hello world!
diff --git a/cmd/testdata/test.txt.letter b/cmd/testdata/test.txt.letter
new file mode 100644
index 0000000..0646238
--- /dev/null
+++ b/cmd/testdata/test.txt.letter
@@ -0,0 +1,13 @@
+�J{
+	"Version": 1,
+	"SuiteID": "signfile_v1",
+	"Nonce": "pKOQBQ==",
+	"Signatures": [
+		{
+			"Scheme": "Ed25519",
+			"ID": "3911c84c-78f7-4354-a7f5-0e115aa2903c",
+			"Value": "ftsIINZ9oApKiXYQTcLIdAZDSflp6nRN/y8Gm0rdQC+3/wal6Q+7N3N8HEAxpoxWseSQNaRVCT9hSnRQStHYBA=="
+		}
+	]
+}
+
hello world!
diff --git a/cmd/testdata/test.txt.sig b/cmd/testdata/test.txt.sig
new file mode 100644
index 0000000..4eaab30
--- /dev/null
+++ b/cmd/testdata/test.txt.sig
@@ -0,0 +1,9 @@
+-----BEGIN JESS SIGNATURE-----
+Q6VnVmVyc2lvbgFnU3VpdGVJRGtzaWduZmlsZV92MWVOb25jZUQb+MqAZERhdGFY
+d02Dq0xhYmVsZWRIYXNoxCIJIOz3Afcn2eLXfEqkmsb7vMmXJ4rKAQvd7rlhwQz1
+TUNaqFNpZ25lZEF01v9iy+uLqE1ldGFEYXRhgqd2ZXJzaW9upTAuMC4xqmlkZW50
+aWZpZXKyd2luZG93cy9jb2RlL3RoaW5nalNpZ25hdHVyZXOBo2ZTY2hlbWVnRWQy
+NTUxOWJJRHgkMzkxMWM4NGMtNzhmNy00MzU0LWE3ZjUtMGUxMTVhYTI5MDNjZVZh
+bHVlWECJZFbIifczUGAJkmATXCHy/MiQZkiktM99X7U/cPgw3IKpKAxQsJ5LobgZ
+4P2ecv0IlN4gQb+x+lycxl93E9sJ
+-----END JESS SIGNATURE-----
\ No newline at end of file
diff --git a/cmd/testdata/test3.txt b/cmd/testdata/test3.txt
new file mode 100644
index 0000000..25c2c9e
--- /dev/null
+++ b/cmd/testdata/test3.txt
@@ -0,0 +1 @@
+hello world!!
diff --git a/cmd/testdata/test3.txt.sig b/cmd/testdata/test3.txt.sig
new file mode 100644
index 0000000..f654070
--- /dev/null
+++ b/cmd/testdata/test3.txt.sig
@@ -0,0 +1,9 @@
+-----BEGIN JESS SIGNATURE-----
+Q6VnVmVyc2lvbgFnU3VpdGVJRGtzaWduZmlsZV92MWVOb25jZUQJ9s/nZERhdGFY
+d02Dq0xhYmVsZWRIYXNoxCIJILtKnL1AHj7YubrWdLu1D+voud8Ky04vh756eTae
+rWQwqFNpZ25lZEF01v9izC6hqE1ldGFEYXRhgqd2ZXJzaW9upTAuMC4xqmlkZW50
+aWZpZXKyd2luZG93cy9jb2RlL3RoaW5nalNpZ25hdHVyZXOBo2ZTY2hlbWVnRWQy
+NTUxOWJJRHgkMzkxMWM4NGMtNzhmNy00MzU0LWE3ZjUtMGUxMTVhYTI5MDNjZVZh
+bHVlWEBLsd2QbM7VmEsnW60hHn/V6EP2mGFauWZgbEOlKTiqumVFbWU4K7Fi91KL
+Zgvwj+CNdZJ7Xv2qR7etviRDCmwC
+-----END JESS SIGNATURE-----
\ No newline at end of file
diff --git a/cmd/testdata/test4.txt b/cmd/testdata/test4.txt
new file mode 100644
index 0000000..a042389
--- /dev/null
+++ b/cmd/testdata/test4.txt
@@ -0,0 +1 @@
+hello world!
diff --git a/cmd/testdata/testdir/test2.txt b/cmd/testdata/testdir/test2.txt
new file mode 100644
index 0000000..a042389
--- /dev/null
+++ b/cmd/testdata/testdir/test2.txt
@@ -0,0 +1 @@
+hello world!
diff --git a/cmd/testdata/testdir/test2.txt.sig b/cmd/testdata/testdir/test2.txt.sig
new file mode 100644
index 0000000..524d2b7
--- /dev/null
+++ b/cmd/testdata/testdir/test2.txt.sig
@@ -0,0 +1,9 @@
+-----BEGIN JESS SIGNATURE-----
+Q6VnVmVyc2lvbgFnU3VpdGVJRGtzaWduZmlsZV92MWVOb25jZUThzxO6ZERhdGFY
+d02Dq0xhYmVsZWRIYXNoxCIJIOz3Afcn2eLXfEqkmsb7vMmXJ4rKAQvd7rlhwQz1
+TUNaqFNpZ25lZEF01v9izC3SqE1ldGFEYXRhgqd2ZXJzaW9upTAuMC4xqmlkZW50
+aWZpZXKyd2luZG93cy9jb2RlL3RoaW5nalNpZ25hdHVyZXOBo2ZTY2hlbWVnRWQy
+NTUxOWJJRHgkMzkxMWM4NGMtNzhmNy00MzU0LWE3ZjUtMGUxMTVhYTI5MDNjZVZh
+bHVlWEAGLkIoej0+ilJrIyb+BzX8+Yw2LY0zkoL9vwI02/2KqKVT7/pH+LTDX1Hl
+h1epYkF8ICdwa1iVNDx6P7iNmWkL
+-----END JESS SIGNATURE-----
\ No newline at end of file
diff --git a/envelope.go b/envelope.go
index 48d0430..2935041 100644
--- a/envelope.go
+++ b/envelope.go
@@ -3,6 +3,10 @@ package jess
 import (
 	"errors"
 	"fmt"
+
+	"github.com/mr-tron/base58"
+
+	"github.com/safing/portbase/formats/dsd"
 )
 
 // Envelope holds configuration for jess to put data into a letter.
@@ -268,3 +272,80 @@ func fillPassword(signet *Signet, createPassword bool, storage TrustStore, minSe
 	}
 	return getPasswordCallback(signet)
 }
+
+// CleanSignets cleans all the signets from all the non-necessary data as well
+// as key material.
+// This is for preparing for serializing and saving the signet.
+func (e *Envelope) CleanSignets() {
+	for i, signet := range e.Secrets {
+		e.Secrets[i] = &Signet{
+			Version: signet.Version,
+			ID:      signet.ID,
+			Scheme:  signet.Scheme,
+		}
+	}
+	for i, signet := range e.Senders {
+		e.Secrets[i] = &Signet{
+			Version: signet.Version,
+			ID:      signet.ID,
+			Scheme:  signet.Scheme,
+		}
+	}
+	for i, signet := range e.Recipients {
+		e.Secrets[i] = &Signet{
+			Version: signet.Version,
+			ID:      signet.ID,
+			Scheme:  signet.Scheme,
+		}
+	}
+}
+
+// ToBytes serializes the envelope to a byte slice.
+func (e *Envelope) ToBytes() ([]byte, error) {
+	// Minimize data and remove any key material.
+	e.CleanSignets()
+
+	// Serialize envelope.
+	data, err := dsd.Dump(e, dsd.CBOR)
+	if err != nil {
+		return nil, fmt.Errorf("failed to serialize the envelope: %w", err)
+	}
+
+	return data, nil
+}
+
+// EnvelopeFromBytes parses and loads a serialized envelope.
+func EnvelopeFromBytes(data []byte) (*Envelope, error) {
+	e := &Envelope{}
+
+	// Parse envelope from data.
+	if _, err := dsd.Load(data, e); err != nil {
+		return nil, fmt.Errorf("failed to parse data format: %w", err)
+	}
+
+	return e, nil
+}
+
+// ToBase58 serializes the envelope and encodes it with base58.
+func (e *Envelope) ToBase58() (string, error) {
+	// Serialize Signet.
+	data, err := e.ToBytes()
+	if err != nil {
+		return "", err
+	}
+
+	// Encode and return.
+	return base58.Encode(data), nil
+}
+
+// EnvelopeFromBase58 parses and loads a base58 encoded serialized envelope.
+func EnvelopeFromBase58(base58Encoded string) (*Envelope, error) {
+	// Decode string.
+	data, err := base58.Decode(base58Encoded)
+	if err != nil {
+		return nil, fmt.Errorf("failed to decode base58: %w", err)
+	}
+
+	// Parse and return.
+	return EnvelopeFromBytes(data)
+}
diff --git a/import_export.go b/import_export.go
new file mode 100644
index 0000000..347d5c2
--- /dev/null
+++ b/import_export.go
@@ -0,0 +1,192 @@
+package jess
+
+import (
+	"errors"
+	"fmt"
+	"regexp"
+	"strings"
+)
+
+const (
+	ExportSenderKeyword = "sender"
+	ExportSenderPrefix  = "sender:"
+
+	ExportRecipientKeyword = "recipient"
+	ExportRecipientPrefix  = "recipient:"
+
+	ExportKeyKeyword = "secret"
+	ExportKeyPrefix  = "secret:"
+
+	ExportEnvelopeKeyword = "envelope"
+	ExportEnvelopePrefix  = "envelope:"
+)
+
+// Export exports the public part of a signet in text format.
+func (signet *Signet) Export(short bool) (textFormat string, err error) {
+	// Make public if needed.
+	if !signet.Public {
+		signet, err = signet.AsRecipient()
+		if err != nil {
+			return "", err
+		}
+	}
+
+	// Transform to text format.
+	return signet.toTextFormat(short)
+}
+
+// Backup exports the private part of a signet in text format.
+func (signet *Signet) Backup(short bool) (textFormat string, err error) {
+	// Abprt if public.
+	if signet.Public {
+		return "", errors.New("cannot backup (only export) a recipient")
+	}
+
+	// Transform to text format.
+	return signet.toTextFormat(short)
+}
+
+func (signet *Signet) toTextFormat(short bool) (textFormat string, err error) {
+	// Serialize to base58.
+	base58data, err := signet.ToBase58()
+	if err != nil {
+		return "", err
+	}
+
+	// Define keywords.
+	var keyword, typeComment string
+	switch {
+	case signet.Scheme == SignetSchemePassword:
+		return "", errors.New("cannot backup or export passwords")
+	case signet.Scheme == SignetSchemeKey:
+		// Check if the signet is marked as "public".
+		if signet.Public {
+			return "", errors.New("cannot export keys")
+		}
+		keyword = ExportKeyKeyword
+		typeComment = "symmetric-key"
+	case signet.Public:
+		keyword = ExportRecipientKeyword
+		typeComment = fmt.Sprintf(
+			"public-%s-key", toTextFormatString(signet.Scheme),
+		)
+	default:
+		keyword = ExportSenderKeyword
+		typeComment = fmt.Sprintf(
+			"private-%s-key", toTextFormatString(signet.Scheme),
+		)
+	}
+
+	// Transform to text format.
+	if short {
+		return fmt.Sprintf(
+			"%s:%s",
+			keyword,
+			base58data,
+		), nil
+	}
+	return fmt.Sprintf(
+		"%s:%s:%s:%s",
+		keyword,
+		typeComment,
+		toTextFormatString(signet.Info.Name),
+		base58data,
+	), nil
+}
+
+// Export exports the envelope in text format.
+func (e *Envelope) Export(short bool) (textFormat string, err error) {
+	// Remove and key data.
+	e.CleanSignets()
+
+	// Serialize to base58.
+	base58data, err := e.ToBase58()
+	if err != nil {
+		return "", err
+	}
+
+	// Transform to text format.
+	if short {
+		return fmt.Sprintf(
+			"%s:%s",
+			ExportEnvelopeKeyword,
+			base58data,
+		), nil
+	}
+	return fmt.Sprintf(
+		"%s:%s:%s:%s",
+		ExportEnvelopeKeyword,
+		e.SuiteID,
+		e.Name,
+		base58data,
+	), nil
+}
+
+// KeyFromTextFormat loads a secret key from the text format.
+func KeyFromTextFormat(textFormat string) (*Signet, error) {
+	// Check the identifier.
+	if !strings.HasPrefix(textFormat, ExportKeyPrefix) {
+		return nil, errors.New("not a secret")
+	}
+
+	// Parse the data section.
+	splitted := strings.Split(textFormat, ":")
+	if len(splitted) < 2 {
+		return nil, errors.New("invalid format")
+	}
+	return SignetFromBase58(splitted[len(splitted)-1])
+}
+
+// SenderFromTextFormat loads a sender (private key) from the text format.
+func SenderFromTextFormat(textFormat string) (*Signet, error) {
+	// Check the identifier.
+	if !strings.HasPrefix(textFormat, ExportSenderPrefix) {
+		return nil, errors.New("not a sender")
+	}
+
+	// Parse the data section.
+	splitted := strings.Split(textFormat, ":")
+	if len(splitted) < 2 {
+		return nil, errors.New("invalid format")
+	}
+	return SignetFromBase58(splitted[len(splitted)-1])
+}
+
+// RecipientFromTextFormat loads a recipient (public key) from the text format.
+func RecipientFromTextFormat(textFormat string) (*Signet, error) {
+	// Check the identifier.
+	if !strings.HasPrefix(textFormat, ExportRecipientPrefix) {
+		return nil, errors.New("not a recipient")
+	}
+
+	// Parse the data section.
+	splitted := strings.Split(textFormat, ":")
+	if len(splitted) < 2 {
+		return nil, errors.New("invalid format")
+	}
+	return SignetFromBase58(splitted[len(splitted)-1])
+}
+
+// EnvelopeFromTextFormat loads an envelope from the text format.
+func EnvelopeFromTextFormat(textFormat string) (*Envelope, error) {
+	// Check the identifier.
+	if !strings.HasPrefix(textFormat, ExportEnvelopePrefix) {
+		return nil, errors.New("not an envelope")
+	}
+
+	// Parse the data section.
+	splitted := strings.Split(textFormat, ":")
+	if len(splitted) < 2 {
+		return nil, errors.New("invalid format")
+	}
+	return EnvelopeFromBase58(splitted[len(splitted)-1])
+}
+
+var replaceForTextFormatMatcher = regexp.MustCompile(`[^A-Za-z\-]+`)
+
+// toTextFormatString makes a string compatible with the text format.
+func toTextFormatString(s string) string {
+	return strings.ToLower(
+		replaceForTextFormatMatcher.ReplaceAllString(s, "_"),
+	)
+}
diff --git a/signet.go b/signet.go
index 367abf4..9a950d8 100644
--- a/signet.go
+++ b/signet.go
@@ -136,6 +136,14 @@ func (signet *Signet) SetLoadedKeys(pubKey crypto.PublicKey, privKey crypto.Priv
 
 // AsRecipient returns a public version of the Signet.
 func (signet *Signet) AsRecipient() (*Signet, error) {
+	// Check special signet schemes.
+	switch signet.Scheme {
+	case SignetSchemeKey:
+		return nil, errors.New("keys cannot be a recipient")
+	case SignetSchemePassword:
+		return nil, errors.New("passwords cannot be a recipient")
+	}
+
 	// load so we can split keys
 	err := signet.LoadKey()
 	if err != nil {