Skip to content

Commit

Permalink
crypto: fix key exports
Browse files Browse the repository at this point in the history
  • Loading branch information
tulir committed Feb 3, 2025
1 parent cf10041 commit 475c4bf
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 33 deletions.
93 changes: 63 additions & 30 deletions crypto/keyexport.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2020 Tulir Asokan
// Copyright (c) 2025 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
Expand All @@ -16,15 +16,21 @@ import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"

"go.mau.fi/util/dbutil"
"go.mau.fi/util/exbytes"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/random"
"golang.org/x/crypto/pbkdf2"

"maunium.net/go/mautrix/id"
)

var ErrNoSessionsForExport = errors.New("no sessions provided for export")

type SenderClaimedKeys struct {
Ed25519 id.Ed25519 `json:"ed25519"`
}
Expand Down Expand Up @@ -78,22 +84,14 @@ func makeExportKeys(passphrase string) (encryptionKey, hashKey, salt, iv []byte)
return
}

func exportSessions(sessions []*InboundGroupSession) ([]ExportedSession, error) {
export := make([]ExportedSession, len(sessions))
func exportSessions(sessions []*InboundGroupSession) ([]*ExportedSession, error) {
export := make([]*ExportedSession, len(sessions))
var err error
for i, session := range sessions {
key, err := session.Internal.Export(session.Internal.FirstKnownIndex())
export[i], err = session.export()
if err != nil {
return nil, fmt.Errorf("failed to export session: %w", err)
}
export[i] = ExportedSession{
Algorithm: id.AlgorithmMegolmV1,
ForwardingChains: session.ForwardingChains,
RoomID: session.RoomID,
SenderKey: session.SenderKey,
SenderClaimedKeys: SenderClaimedKeys{},
SessionID: session.ID(),
SessionKey: string(key),
}
}
return export, nil
}
Expand All @@ -107,38 +105,73 @@ func exportSessionsJSON(sessions []*InboundGroupSession) ([]byte, error) {
}

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
encodedLen := base64.StdEncoding.EncodedLen(len(data))
outputLength := len(exportPrefix) +
len(base64Data) + int(math.Ceil(float64(len(base64Data))/exportLineLengthLimit)) +
encodedLen + int(math.Ceil(float64(encodedLen)/exportLineLengthLimit)) +
len(exportSuffix)
output := make([]byte, 0, outputLength)
outputWriter := (*exbytes.Writer)(&output)
base64Writer := base64.NewEncoder(base64.StdEncoding, outputWriter)
lineByteCount := base64.StdEncoding.DecodedLen(exportLineLengthLimit)
exerrors.Must(outputWriter.WriteString(exportPrefix))
for i := 0; i < len(data); i += lineByteCount {
exerrors.Must(base64Writer.Write(data[i:min(i+lineByteCount, len(data))]))
if i+lineByteCount >= len(data) {
exerrors.PanicIfNotNil(base64Writer.Close())
}
exerrors.PanicIfNotNil(outputWriter.WriteByte('\n'))
}
exerrors.Must(outputWriter.WriteString(exportSuffix))
if len(output) != outputLength {
panic(fmt.Errorf("unexpected length %d / %d", len(output), outputLength))
}
return output
}

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')
func ExportKeysIter(passphrase string, sessions dbutil.RowIter[*InboundGroupSession]) ([]byte, error) {
buf := bytes.NewBuffer(make([]byte, 0, 50*1024))
enc := json.NewEncoder(buf)
buf.WriteByte('[')
err := sessions.Iter(func(session *InboundGroupSession) (bool, error) {
exported, err := session.export()
if err != nil {
return false, err
}
err = enc.Encode(exported)
if err != nil {
return false, err
}
buf.WriteByte(',')
return true, nil
})
if err != nil {
return nil, err
}
buf.WriteString(exportSuffix)
if buf.Len() != buf.Cap() || buf.Len() != outputLength {
panic(fmt.Errorf("unexpected length %d / %d / %d", buf.Len(), buf.Cap(), outputLength))
output := buf.Bytes()
if len(output) == 1 {
return nil, ErrNoSessionsForExport
}
return buf.Bytes()
output[len(output)-1] = ']' // Replace the last comma with a closing bracket
return EncryptKeyExport(passphrase, output)
}

// ExportKeys exports the given Megolm sessions with the format specified in the Matrix spec.
// See https://spec.matrix.org/v1.2/client-server-api/#key-exports
func ExportKeys(passphrase string, sessions []*InboundGroupSession) ([]byte, error) {
// Make all the keys necessary for exporting
encryptionKey, hashKey, salt, iv := makeExportKeys(passphrase)
if len(sessions) == 0 {
return nil, ErrNoSessionsForExport
}
// Export all the given sessions and put them in JSON
unencryptedData, err := exportSessionsJSON(sessions)
if err != nil {
return nil, err
}
return EncryptKeyExport(passphrase, unencryptedData)
}

func EncryptKeyExport(passphrase string, unencryptedData json.RawMessage) ([]byte, error) {
// Make all the keys necessary for exporting
encryptionKey, hashKey, salt, iv := makeExportKeys(passphrase)

// The export data consists of:
// 1 byte of export format version
Expand Down
35 changes: 35 additions & 0 deletions crypto/keyexport_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) 2024 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_test

import (
"testing"

"github.com/stretchr/testify/assert"

"go.mau.fi/util/exerrors"
"go.mau.fi/util/exfmt"

"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/olm"
)

func TestExportKeys(t *testing.T) {
acc := crypto.NewOlmAccount()
sess := exerrors.Must(crypto.NewInboundGroupSession(
acc.IdentityKey(),
acc.SigningKey(),
"!room:example.com",
exerrors.Must(olm.NewOutboundGroupSession()).Key(),
7*exfmt.Day,
100,
false,
))
data, err := crypto.ExportKeys("meow", []*crypto.InboundGroupSession{sess})
assert.NoError(t, err)
assert.Len(t, data, 840)
}
4 changes: 4 additions & 0 deletions crypto/keyimport.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ var (
var exportPrefixBytes, exportSuffixBytes = []byte(exportPrefix), []byte(exportSuffix)

func decodeKeyExport(data []byte) ([]byte, error) {
// Fix some types of corruption in the key export file before checking anything
if bytes.IndexByte(data, '\r') != -1 {
data = bytes.ReplaceAll(data, []byte{'\r', '\n'}, []byte{'\n'})
}
// If the valid prefix and suffix aren't there, it's probably not a Matrix key export
if !bytes.HasPrefix(data, exportPrefixBytes) {
return nil, ErrMissingExportPrefix
Expand Down
17 changes: 17 additions & 0 deletions crypto/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package crypto

import (
"errors"
"fmt"
"time"

"maunium.net/go/mautrix/crypto/olm"
Expand Down Expand Up @@ -152,6 +153,22 @@ func (igs *InboundGroupSession) RatchetTo(index uint32) error {
return nil
}

func (igs *InboundGroupSession) export() (*ExportedSession, error) {
key, err := igs.Internal.Export(igs.Internal.FirstKnownIndex())
if err != nil {
return nil, fmt.Errorf("failed to export session: %w", err)
}
return &ExportedSession{
Algorithm: id.AlgorithmMegolmV1,
ForwardingChains: igs.ForwardingChains,
RoomID: igs.RoomID,
SenderKey: igs.SenderKey,
SenderClaimedKeys: SenderClaimedKeys{},
SessionID: igs.ID(),
SessionKey: string(key),
}, nil
}

type OGSState int

const (
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/yuin/goldmark v1.7.8
go.mau.fi/util v0.8.5-0.20250129121406-18c356e558b8
go.mau.fi/util v0.8.5-0.20250203220331-1c0d19ea6003
go.mau.fi/zeroconfig v0.1.3
golang.org/x/crypto v0.32.0
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.5-0.20250129121406-18c356e558b8 h1:O1cRlXPahwbu1ckIf8XgUP3gHMJlSqJxaVTqwRlVK4s=
go.mau.fi/util v0.8.5-0.20250129121406-18c356e558b8/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
go.mau.fi/util v0.8.5-0.20250203220331-1c0d19ea6003 h1:ye5l+QpYW5CpGVMedb3EHlmflGMQsMtw8mC4K/U8hIw=
go.mau.fi/util v0.8.5-0.20250203220331-1c0d19ea6003/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
Expand Down

0 comments on commit 475c4bf

Please sign in to comment.