From a159b386f6a0e625bf81a01d836fabbffaf6db36 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 26 Aug 2020 15:16:37 +0300 Subject: [PATCH] Improve key import/export --- crypto/keyexport.go | 85 +++++++++++++++++++++++++-------------------- crypto/keyimport.go | 62 +++++++++++++++++++++++---------- version.go | 2 +- 3 files changed, 92 insertions(+), 57 deletions(-) diff --git a/crypto/keyexport.go b/crypto/keyexport.go index 6454e3dc..bebdc32b 100644 --- a/crypto/keyexport.go +++ b/crypto/keyexport.go @@ -7,6 +7,7 @@ package crypto import ( + "bytes" "crypto/aes" "crypto/cipher" "crypto/hmac" @@ -16,12 +17,13 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" + "fmt" "math" - "strings" "github.com/pkg/errors" "golang.org/x/crypto/pbkdf2" + "maunium.net/go/mautrix/crypto/olm" "maunium.net/go/mautrix/id" ) @@ -39,8 +41,20 @@ type ExportedSession struct { SessionKey string `json:"session_key"` } +// The default number of pbkdf2 rounds to use when exporting keys const defaultPassphraseRounds = 100000 +const exportPrefix = "-----BEGIN MEGOLM SESSION DATA-----\n" +const exportSuffix = "-----END MEGOLM SESSION DATA-----\n" +// Only version 0x01 is currently specified in the spec +const exportVersion1 = 0x01 +// The standard for wrapping base64 is 76 bytes +const exportLineLengthLimit = 76 +// Byte count for version + salt + iv + number of rounds +const exportHeaderLength = 1 + 16 + 16 + 4 +// SHA-256 hash length +const exportHashLength = 32 + func computeKey(passphrase string, salt []byte, rounds int) (encryptionKey, hashKey []byte) { key := pbkdf2.Key([]byte(passphrase), salt, rounds, 64, sha512.New) encryptionKey = key[:32] @@ -48,27 +62,27 @@ func computeKey(passphrase string, salt []byte, rounds int) (encryptionKey, hash return } -func makeExportIV() ([]byte, error) { +func makeExportIV() []byte { iv := make([]byte, 16) _, err := rand.Read(iv) if err != nil { - return nil, err + panic(olm.NotEnoughGoRandom) } // Set bit 63 to zero iv[7] &= 0b11111110 - return iv, nil + return iv } -func makeExportKeys(passphrase string) (encryptionKey, hashKey, salt, iv []byte, err error) { +func makeExportKeys(passphrase string) (encryptionKey, hashKey, salt, iv []byte) { salt = make([]byte, 16) - _, err = rand.Read(salt) + _, err := rand.Read(salt) if err != nil { - return + panic(olm.NotEnoughGoRandom) } encryptionKey, hashKey = computeKey(passphrase, salt, defaultPassphraseRounds) - iv, err = makeExportIV() + iv = makeExportIV() return } @@ -100,12 +114,6 @@ func exportSessionsJSON(sessions []*InboundGroupSession) ([]byte, error) { return json.Marshal(exportedSessions) } -const exportPrefix = "-----BEGIN MEGOLM SESSION DATA-----" -const exportSuffix = "-----END MEGOLM SESSION DATA-----" -const exportLineLengthLimit = 76 -const exportHeaderLength = 1 + 16 + 16 + 4 -const exportHashLength = 32 - func min(a, b int) int { if a > b { return b @@ -113,32 +121,38 @@ func min(a, b int) int { return a } -func formatKeyExportData(data []byte) string { - dataStr := base64.StdEncoding.EncodeToString(data) - // Base64 lines + prefix + suffix + empty line at end - lines := make([]string, int(math.Ceil(float64(len(dataStr)) / exportLineLengthLimit)) + 3) - lines[0] = exportPrefix - line := 1 - for ptr := 0; ptr < len(dataStr); ptr += exportLineLengthLimit { - lines[line] = dataStr[ptr:min(ptr+exportLineLengthLimit, len(dataStr))] - line++ +func formatKeyExportData(data []byte) []byte { + base64Data := make([]byte, base64.StdEncoding.EncodedLen(len(data))) + base64.StdEncoding.Encode(base64Data, data) + + // Prefix + data and newline for each 76 characters of data + suffix + outputLength := len(exportPrefix) + + len(base64Data) + int(math.Ceil(float64(len(base64Data))/exportLineLengthLimit)) + + len(exportSuffix) + + var buf bytes.Buffer + buf.Grow(outputLength) + buf.WriteString(exportPrefix) + for ptr := 0; ptr < len(base64Data); ptr += exportLineLengthLimit { + buf.Write(base64Data[ptr:min(ptr+exportLineLengthLimit, len(base64Data))]) + buf.WriteRune('\n') } - lines[len(lines)-2] = exportSuffix - return strings.Join(lines, "\n") + buf.WriteString(exportSuffix) + if buf.Len() != buf.Cap() || buf.Len() != outputLength { + panic(fmt.Errorf("unexpected length %d / %d / %d", buf.Len(), buf.Cap(), outputLength)) + } + return buf.Bytes() } // ExportKeys exports the given Megolm sessions with the format specified in the Matrix spec. // See https://matrix.org/docs/spec/client_server/r0.6.1#key-exports -func ExportKeys(passphrase string, sessions []*InboundGroupSession) (string, error) { +func ExportKeys(passphrase string, sessions []*InboundGroupSession) ([]byte, error) { // Make all the keys necessary for exporting - encryptionKey, hashKey, salt, iv, err := makeExportKeys(passphrase) - if err != nil { - return "", err - } + encryptionKey, hashKey, salt, iv := makeExportKeys(passphrase) // Export all the given sessions and put them in JSON unencryptedData, err := exportSessionsJSON(sessions) if err != nil { - return "", err + return nil, err } // The export data consists of: @@ -153,16 +167,13 @@ func ExportKeys(passphrase string, sessions []*InboundGroupSession) (string, err dataWithoutHashLength := len(exportData) - exportHashLength // Create the header for the export data - exportData[0] = 0x01 + exportData[0] = exportVersion1 copy(exportData[1:17], salt) copy(exportData[17:33], iv) binary.BigEndian.PutUint32(exportData[33:37], defaultPassphraseRounds) // Encrypt data with AES-256-CTR - block, err := aes.NewCipher(encryptionKey) - if err != nil { - return "", err - } + block, _ := aes.NewCipher(encryptionKey) cipher.NewCTR(block, iv).XORKeyStream(exportData[exportHeaderLength:dataWithoutHashLength], unencryptedData) // Hash all the data with HMAC-SHA256 and put it at the end @@ -172,4 +183,4 @@ func ExportKeys(passphrase string, sessions []*InboundGroupSession) (string, err // Format the export (prefix, base64'd exportData, suffix) and return return formatKeyExportData(exportData), nil -} \ No newline at end of file +} diff --git a/crypto/keyimport.go b/crypto/keyimport.go index 5d26fd19..4c8fc163 100644 --- a/crypto/keyimport.go +++ b/crypto/keyimport.go @@ -15,7 +15,6 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" - "strings" "github.com/pkg/errors" @@ -32,8 +31,30 @@ var ( ErrMismatchingExportedSessionID = errors.New("imported session has different ID than expected") ) +var exportPrefixBytes, exportSuffixBytes = []byte(exportPrefix), []byte(exportSuffix) + +func decodeKeyExport(data []byte) ([]byte, error) { + // If there valid prefix and suffix aren't there, it's probably not a Matrix key export + if !bytes.HasPrefix(data, exportPrefixBytes) { + return nil, ErrMissingExportPrefix + } else if !bytes.HasSuffix(data, exportSuffixBytes) { + return nil, ErrMissingExportSuffix + } + // Remove the prefix and suffix, we don't care about them anymore + data = data[len(exportPrefix) : len(data)-len(exportSuffix)] + + // Allocate space for the decoded data. Ignore newlines when counting the length + exportData := make([]byte, base64.StdEncoding.DecodedLen(len(data)-bytes.Count(data, []byte{'\n'}))) + n, err := base64.StdEncoding.Decode(exportData, data) + if err != nil { + return nil, err + } + + return exportData[:n], nil +} + func decryptKeyExport(passphrase string, exportData []byte) ([]ExportedSession, error) { - if exportData[0] != 0x01 { + if exportData[0] != exportVersion1 { return nil, ErrUnsupportedExportVersion } @@ -51,7 +72,7 @@ func decryptKeyExport(passphrase string, exportData []byte) ([]ExportedSession, // Compute and verify the hash. If it doesn't match, the passphrase is probably wrong mac := hmac.New(sha256.New, hashKey) mac.Write(exportData[:dataWithoutHashLength]) - if bytes.Compare(hash, mac.Sum(nil)) != 0 { + if !bytes.Equal(hash, mac.Sum(nil)) { return nil, ErrMismatchingExportHash } @@ -69,16 +90,16 @@ func decryptKeyExport(passphrase string, exportData []byte) ([]ExportedSession, return sessionsJSON, nil } -func (mach *OlmMachine) importExportedRoomKey(session ExportedSession) error { +func (mach *OlmMachine) importExportedRoomKey(session ExportedSession) (bool, error) { if session.Algorithm != id.AlgorithmMegolmV1 { - return ErrInvalidExportedAlgorithm + return false, ErrInvalidExportedAlgorithm } igsInternal, err := olm.InboundGroupSessionImport([]byte(session.SessionKey)) if err != nil { - return errors.Wrap(err, "failed to import session") + return false, errors.Wrap(err, "failed to import session") } else if igsInternal.ID() != session.SessionID { - return ErrMismatchingExportedSessionID + return false, ErrMismatchingExportedSessionID } igs := &InboundGroupSession{ Internal: *igsInternal, @@ -88,21 +109,22 @@ func (mach *OlmMachine) importExportedRoomKey(session ExportedSession) error { // TODO should we add something here to mark the signing key as unverified like key requests do? ForwardingChains: session.ForwardingChains, } - // TODO we probably shouldn't override existing sessions (except maybe if earliest known session is lower than the one in the store?) + existingIGS, _ := mach.CryptoStore.GetGroupSession(igs.RoomID, igs.SenderKey, igs.ID()) + if existingIGS != nil && existingIGS.Internal.FirstKnownIndex() <= igs.Internal.FirstKnownIndex() { + // We already have an equivalent or better session in the store, so don't override it. + return false, nil + } err = mach.CryptoStore.PutGroupSession(igs.RoomID, igs.SenderKey, igs.ID(), igs) if err != nil { - return errors.Wrap(err, "failed to store imported session") + return false, errors.Wrap(err, "failed to store imported session") } - return nil + return true, nil } -func (mach *OlmMachine) ImportKeys(passphrase string, data string) (int, int, error) { - if !strings.HasPrefix(data, exportPrefix) { - return 0, 0, ErrMissingExportPrefix - } else if !strings.HasSuffix(data, exportSuffix) && !strings.HasSuffix(data, exportSuffix + "\n") { - return 0, 0, ErrMissingExportSuffix - } - exportData, err := base64.StdEncoding.DecodeString(data[len(exportPrefix) : len(data)-len(exportPrefix)]) +// ImportKeys imports data that was exported with the format specified in the Matrix spec. +// See See https://matrix.org/docs/spec/client_server/r0.6.1#key-exports +func (mach *OlmMachine) ImportKeys(passphrase string, data []byte) (int, int, error) { + exportData, err := decodeKeyExport(data) if err != nil { return 0, 0, err } @@ -113,12 +135,14 @@ func (mach *OlmMachine) ImportKeys(passphrase string, data string) (int, int, er count := 0 for _, session := range sessions { - err := mach.importExportedRoomKey(session) + imported, err := mach.importExportedRoomKey(session) if err != nil { mach.Log.Warn("Failed to import Megolm session %s/%s from file: %v", session.RoomID, session.SessionID, err) - } else { + } else if imported { mach.Log.Debug("Imported Megolm session %s/%s from file", session.RoomID, session.SessionID) count++ + } else { + mach.Log.Debug("Skipped Megolm session %s/%s: already in store", session.RoomID, session.SessionID) } } return count, len(sessions), nil diff --git a/version.go b/version.go index 2ace040e..691036f9 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package mautrix -const Version = "v0.7.3" +const Version = "v0.7.4"