From cf2f5430ef01809bf11fb19fd2143028f8aa0027 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 25 Aug 2020 19:55:18 +0300 Subject: [PATCH] Add methods for exporting and importing key files --- crypto/keyexport.go | 163 ++++++++++++++++++++++++++++++++++++++++++++ crypto/keyimport.go | 123 +++++++++++++++++++++++++++++++++ crypto/sql_store.go | 55 +++++++++++++++ crypto/store.go | 41 ++++++++++- 4 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 crypto/keyexport.go create mode 100644 crypto/keyimport.go diff --git a/crypto/keyexport.go b/crypto/keyexport.go new file mode 100644 index 00000000..c582848a --- /dev/null +++ b/crypto/keyexport.go @@ -0,0 +1,163 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/binary" + "encoding/json" + "math" + "strings" + + "github.com/pkg/errors" + "golang.org/x/crypto/pbkdf2" + + "maunium.net/go/mautrix/id" +) + +type SenderClaimedKeys struct { + Ed25519 id.Ed25519 `json:"ed25519"` +} + +type ExportedSession struct { + Algorithm id.Algorithm `json:"algorithm"` + ForwardingChains []string `json:"forwarding_curve25519_key_chain"` + RoomID id.RoomID `json:"room_id"` + SenderKey id.SenderKey `json:"sender_key"` + SenderClaimedKeys SenderClaimedKeys `json:"sender_claimed_keys"` + SessionID id.SessionID `json:"session_id"` + SessionKey string `json:"session_key"` +} + +const defaultPassphraseRounds = 100000 + +func computeKey(passphrase string, salt []byte, rounds int) []byte { + return pbkdf2.Key([]byte(passphrase), salt, rounds, 512, sha512.New) +} + +func makeExportIV() ([]byte, error) { + iv := make([]byte, 16) + _, err := rand.Read(iv) + if err != nil { + return nil, err + } + // Set bit 63 to zero + iv[7] &= 0b10000000 + return iv, nil +} + +func makeExportKeys(passphrase string) (encryptionKey, hashKey, salt, iv []byte, err error) { + salt = make([]byte, 16) + _, err = rand.Read(salt) + if err != nil { + return + } + + key := computeKey(passphrase, salt, defaultPassphraseRounds) + encryptionKey = key[:256] + hashKey = key[256:] + + iv, err = makeExportIV() + return +} + +func exportSessions(sessions []*InboundGroupSession) ([]ExportedSession, error) { + export := make([]ExportedSession, len(sessions)) + for i, session := range sessions { + key, err := session.Internal.Export(session.Internal.FirstKnownIndex()) + if err != nil { + return nil, errors.Wrap(err, "failed to export session") + } + export[i] = ExportedSession{ + Algorithm: id.AlgorithmMegolmV1, + ForwardingChains: session.ForwardingChains, + RoomID: session.RoomID, + SenderKey: session.SenderKey, + SenderClaimedKeys: SenderClaimedKeys{}, + SessionID: session.ID(), + SessionKey: key, + } + } + return export, nil +} + +func exportSessionsJSON(sessions []*InboundGroupSession) ([]byte, error) { + exportedSessions, err := exportSessions(sessions) + if err != nil { + return nil, err + } + 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 formatKeyExportData(data []byte) string { + dataStr := base64.StdEncoding.EncodeToString(data) + lines := make([]string, math.Ceil(float64(len(dataStr)) / exportLineLengthLimit) + 2) + lines[0] = exportPrefix + line := 1 + for ptr := 0; ptr < len(dataStr); ptr += exportLineLengthLimit { + lines[line] = dataStr[ptr:ptr+exportLineLengthLimit] + line++ + } + lines[len(lines)-1] = exportSuffix + return strings.Join(lines, "\n") +} + +// 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) { + // Make all the keys necessary for exporting + encryptionKey, hashKey, salt, iv, err := makeExportKeys(passphrase) + if err != nil { + return "", err + } + // Export all the given sessions and put them in JSON + unencryptedData, err := exportSessionsJSON(sessions) + if err != nil { + return "", err + } + + // The export data consists of: + // 1 byte of export format version + // 16 bytes of salt + // 16 bytes of IV (initialization vector) + // 4 bytes of the number of rounds + // the encrypted export data + // 32 bytes of the hash of all the data above + + exportData := make([]byte, exportHeaderLength+len(unencryptedData)+exportHashLength) + dataWithoutHashLength := len(exportData) - exportHashLength + + // Create the header for the export data + exportData[0] = 0x01 + copy(exportData[1:17], salt) + copy(exportData[17:33], iv) + binary.BigEndian.PutUint32(exportData[33:37], defaultPassphraseRounds) + + // Encrypt data with AES-256-CTR + 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 + mac := hmac.New(sha256.New, hashKey) + mac.Write(exportData[:dataWithoutHashLength]) + mac.Sum(exportData[dataWithoutHashLength:]) + + // 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 new file mode 100644 index 00000000..93014109 --- /dev/null +++ b/crypto/keyimport.go @@ -0,0 +1,123 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "strings" + + "github.com/pkg/errors" + + "maunium.net/go/mautrix/crypto/olm" + "maunium.net/go/mautrix/id" +) + +var ( + ErrMissingExportPrefix = errors.New("invalid Matrix key export: missing prefix/suffix") + ErrUnsupportedExportVersion = errors.New("unsupported Matrix key export format version") + ErrMismatchingExportHash = errors.New("mismatching hash; incorrect passphrase?") + ErrInvalidExportedAlgorithm = errors.New("session has unknown algorithm") + ErrMismatchingExportedSessionID = errors.New("imported session has different ID than expected") +) + +func decryptKeyExport(passphrase string, exportData []byte) ([]ExportedSession, error) { + if exportData[0] != 0x01 { + return nil, ErrUnsupportedExportVersion + } + + // Get all the different parts of the export + salt := exportData[1:17] + iv := exportData[17:33] + passphraseRounds := binary.BigEndian.Uint32(exportData[33:37]) + dataWithoutHashLength := len(exportData) - exportHashLength + encryptedData := exportData[exportHeaderLength:dataWithoutHashLength] + hash := exportData[dataWithoutHashLength:] + + // Compute the encryption and hash keys from the passphrase and salt + key := computeKey(passphrase, salt, int(passphraseRounds)) + encryptionKey := key[:256] + hashKey := key[256:] + + // 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 { + return nil, ErrMismatchingExportHash + } + + // Decrypt the export + block, _ := aes.NewCipher(encryptionKey) + unencryptedData := make([]byte, len(exportData)-exportHashLength-exportHeaderLength) + cipher.NewCTR(block, iv).XORKeyStream(unencryptedData, encryptedData) + + // Parse the decrypted JSON + var sessionsJSON []ExportedSession + err := json.Unmarshal(unencryptedData, &sessionsJSON) + if err != nil { + return nil, errors.Wrap(err, "invalid export json") + } + return sessionsJSON, nil +} + +func (mach *OlmMachine) importExportedRoomKey(session ExportedSession) error { + if session.Algorithm != id.AlgorithmMegolmV1 { + return ErrInvalidExportedAlgorithm + } + + igsInternal, err := olm.InboundGroupSessionImport([]byte(session.SessionKey)) + if err != nil { + return errors.Wrap(err, "failed to import session") + } else if igsInternal.ID() != session.SessionID { + return ErrMismatchingExportedSessionID + } + igs := &InboundGroupSession{ + Internal: *igsInternal, + SigningKey: session.SenderClaimedKeys.Ed25519, + SenderKey: session.SenderKey, + RoomID: session.RoomID, + // TODO should we add something here to mark the signing key as unverified like key requests do? + ForwardingChains: session.ForwardingChains, + } + err = mach.CryptoStore.PutGroupSession(igs.RoomID, igs.SenderKey, igs.ID(), igs) + if err != nil { + return errors.Wrap(err, "failed to store imported session") + } + return nil +} + +func (mach *OlmMachine) ImportKeys(passphrase string, data string) (int, error) { + if !strings.HasPrefix(data, exportPrefix) || !strings.HasSuffix(data, exportSuffix) { + return 0, ErrMissingExportPrefix + } + exportData, err := base64.StdEncoding.DecodeString(data[len(exportPrefix) : len(data)-len(exportPrefix)]) + if err != nil { + return 0, err + } + sessions, err := decryptKeyExport(passphrase, exportData) + if err != nil { + return 0, err + } + + count := 0 + for _, session := range sessions { + 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 { + mach.Log.Debug("Imported Megolm session %s/%s from file", session.RoomID, session.SessionID) + count++ + } + } + return count, nil +} diff --git a/crypto/sql_store.go b/crypto/sql_store.go index b11f2119..d1cb7f2a 100644 --- a/crypto/sql_store.go +++ b/crypto/sql_store.go @@ -268,6 +268,61 @@ func (store *SQLCryptoStore) GetWithheldGroupSession(roomID id.RoomID, senderKey }, nil } +func (store *SQLCryptoStore) scanGroupSessionList(rows *sql.Rows) (result []*InboundGroupSession) { + for rows.Next() { + var roomID id.RoomID + var signingKey, senderKey, forwardingChains sql.NullString + var sessionBytes []byte + err := rows.Scan(&roomID, &signingKey, &senderKey, &sessionBytes, &forwardingChains) + if err != nil { + store.Log.Warn("Failed to scan row: %v", err) + continue + } + igs := olm.NewBlankInboundGroupSession() + err = igs.Unpickle(sessionBytes, store.PickleKey) + if err != nil { + store.Log.Warn("Failed to unpickle session: %v", err) + continue + } + result = append(result, &InboundGroupSession{ + Internal: *igs, + SigningKey: id.Ed25519(signingKey.String), + SenderKey: id.Curve25519(senderKey.String), + RoomID: roomID, + ForwardingChains: strings.Split(forwardingChains.String, ","), + }) + } + return +} + +func (store *SQLCryptoStore) GetGroupSessionsForRoom(roomID id.RoomID) ([]*InboundGroupSession, error) { + rows, err := store.DB.Query(` + SELECT room_id, signing_key, sender_key, session, forwarding_chains + FROM crypto_megolm_inbound_session WHERE room_id=$1 AND account_id=$2`, + roomID, store.AccountID, + ) + if err == sql.ErrNoRows { + return []*InboundGroupSession{}, nil + } else if err != nil { + return nil, err + } + return store.scanGroupSessionList(rows), nil +} + +func (store *SQLCryptoStore) GetAllGroupSessions() ([]*InboundGroupSession, error) { + rows, err := store.DB.Query(` + SELECT room_id, signing_key, sender_key, session, forwarding_chains + FROM crypto_megolm_inbound_session WHERE account_id=$2`, + store.AccountID, + ) + if err == sql.ErrNoRows { + return []*InboundGroupSession{}, nil + } else if err != nil { + return nil, err + } + return store.scanGroupSessionList(rows), nil +} + // AddOutboundGroupSession stores an outbound Megolm session, along with the information about the room and involved devices. func (store *SQLCryptoStore) AddOutboundGroupSession(session *OutboundGroupSession) (err error) { sessionBytes := session.Internal.Pickle(store.PickleKey) diff --git a/crypto/store.go b/crypto/store.go index ea6f7ecc..674bfb70 100644 --- a/crypto/store.go +++ b/crypto/store.go @@ -100,6 +100,13 @@ type Store interface { // GetWithheldGroupSession gets the event content that was previously inserted with PutWithheldGroupSession. GetWithheldGroupSession(id.RoomID, id.SenderKey, id.SessionID) (*event.RoomKeyWithheldEventContent, error) + // GetGroupSessionsForRoom gets all the inbound Megolm sessions for a specific room. This is used for creating key + // export files. Unlike GetGroupSession, this should not return any errors about withheld keys. + GetGroupSessionsForRoom(id.RoomID) ([]*InboundGroupSession, error) + // GetGroupSessionsForRoom gets all the inbound Megolm sessions in the store. This is used for creating key export + // files. Unlike GetGroupSession, this should not return any errors about withheld keys. + GetAllGroupSessions() ([]*InboundGroupSession, error) + // AddOutboundGroupSession inserts the given outbound Megolm session into the store. // // The store should index inserted sessions by the RoomID field to support getting and removing sessions. @@ -239,7 +246,7 @@ func (gs *GobStore) AddSession(senderKey id.SenderKey, session *OlmSession) erro return err } -func (gs *GobStore) UpdateSession(key id.SenderKey, session *OlmSession) error { +func (gs *GobStore) UpdateSession(_ id.SenderKey, _ *OlmSession) error { // we don't need to do anything here because the session is a pointer and already stored in our map return gs.save() } @@ -330,6 +337,36 @@ func (gs *GobStore) GetWithheldGroupSession(roomID id.RoomID, senderKey id.Sende return session, nil } +func (gs *GobStore) GetGroupSessionsForRoom(roomID id.RoomID) ([]*InboundGroupSession, error) { + gs.lock.Lock() + room, ok := gs.GroupSessions[roomID] + if !ok { + return []*InboundGroupSession{}, nil + } + var result []*InboundGroupSession + for _, sessions := range room { + for _, session := range sessions { + result = append(result, session) + } + } + gs.lock.Unlock() + return result, nil +} + +func (gs *GobStore) GetAllGroupSessions() ([]*InboundGroupSession, error) { + gs.lock.Lock() + var result []*InboundGroupSession + for _, room := range gs.GroupSessions { + for _, sessions := range room { + for _, session := range sessions { + result = append(result, session) + } + } + } + gs.lock.Unlock() + return result, nil +} + func (gs *GobStore) AddOutboundGroupSession(session *OutboundGroupSession) error { gs.lock.Lock() gs.OutGroupSessions[session.RoomID] = session @@ -338,7 +375,7 @@ func (gs *GobStore) AddOutboundGroupSession(session *OutboundGroupSession) error return err } -func (gs *GobStore) UpdateOutboundGroupSession(session *OutboundGroupSession) error { +func (gs *GobStore) UpdateOutboundGroupSession(_ *OutboundGroupSession) error { // we don't need to do anything here because the session is a pointer and already stored in our map return gs.save() }