mirror of
https://mau.dev/mautrix/go.git
synced 2026-03-14 22:35:52 +01:00
204 lines
6.2 KiB
Go
204 lines
6.2 KiB
Go
// 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
|
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
package crypto
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/sha512"
|
|
"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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// 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]
|
|
hashKey = key[32:]
|
|
return
|
|
}
|
|
|
|
func makeExportIV() []byte {
|
|
iv := random.Bytes(16)
|
|
// Set bit 63 to zero
|
|
iv[7] &= 0b11111110
|
|
return iv
|
|
}
|
|
|
|
func makeExportKeys(passphrase string) (encryptionKey, hashKey, salt, iv []byte) {
|
|
salt = random.Bytes(16)
|
|
encryptionKey, hashKey = computeKey(passphrase, salt, defaultPassphraseRounds)
|
|
iv = makeExportIV()
|
|
return
|
|
}
|
|
|
|
func exportSessions(sessions []*InboundGroupSession) ([]*ExportedSession, error) {
|
|
export := make([]*ExportedSession, len(sessions))
|
|
var err error
|
|
for i, session := range sessions {
|
|
export[i], err = session.export()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to export session: %w", err)
|
|
}
|
|
}
|
|
return export, nil
|
|
}
|
|
|
|
func exportSessionsJSON(sessions []*InboundGroupSession) ([]byte, error) {
|
|
exportedSessions, err := exportSessions(sessions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(exportedSessions)
|
|
}
|
|
|
|
func formatKeyExportData(data []byte) []byte {
|
|
encodedLen := base64.StdEncoding.EncodedLen(len(data))
|
|
outputLength := len(exportPrefix) +
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
output := buf.Bytes()
|
|
if len(output) == 1 {
|
|
return nil, ErrNoSessionsForExport
|
|
}
|
|
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) {
|
|
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
|
|
// 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] = 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, _ := 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
|
|
}
|