cluster

Infrastructure files for Nordgedanken and Midnightthoughts.
git clone git://archive.git.mtrnord.blog/MTRNord/cluster.git
Log | Files | Refs | README

keys.go (12655B)


      1 // keys.go — SSSS key derivation, Megolm key-backup export, and in-memory
      2 // session decryption used during history pagination.
      3 package main
      4 
      5 import (
      6 	"context"
      7 	"crypto/aes"
      8 	"crypto/cipher"
      9 	"crypto/hmac"
     10 	"crypto/rand"
     11 	"crypto/sha256"
     12 	"crypto/sha512"
     13 	"encoding/base64"
     14 	"encoding/binary"
     15 	"encoding/json"
     16 	"errors"
     17 	"fmt"
     18 	"log/slog"
     19 	"net/url"
     20 	"strings"
     21 
     22 	"golang.org/x/crypto/pbkdf2"
     23 	"maunium.net/go/mautrix"
     24 	"maunium.net/go/mautrix/crypto/backup"
     25 	"maunium.net/go/mautrix/crypto/goolm/session"
     26 	"maunium.net/go/mautrix/crypto/ssss"
     27 	"maunium.net/go/mautrix/event"
     28 	"maunium.net/go/mautrix/id"
     29 )
     30 
     31 // ─────────────────────────────────────────────────────────────────────────────
     32 // Types
     33 // ─────────────────────────────────────────────────────────────────────────────
     34 
     35 type defaultKeyEventContent struct {
     36 	Key string `json:"key"`
     37 }
     38 
     39 // encryptedSecretContent matches the `{"encrypted": {"<keyID>": {...}}}` format.
     40 type encryptedSecretContent struct {
     41 	Encrypted map[string]ssss.EncryptedKeyData `json:"encrypted"`
     42 }
     43 
     44 type keyBackupVersionResp struct {
     45 	Version   string `json:"version"`
     46 	Algorithm string `json:"algorithm"`
     47 }
     48 
     49 type keyBackupRoomSession struct {
     50 	FirstMessageIndex int             `json:"first_message_index"`
     51 	ForwardedCount    int             `json:"forwarded_count"`
     52 	IsVerified        bool            `json:"is_verified"`
     53 	SessionData       json.RawMessage `json:"session_data"`
     54 }
     55 
     56 type keyBackupRoom struct {
     57 	Sessions map[string]keyBackupRoomSession `json:"sessions"`
     58 }
     59 
     60 type keyBackupAllRooms struct {
     61 	Rooms map[id.RoomID]keyBackupRoom `json:"rooms"`
     62 }
     63 
     64 // exportedSessionEntry matches the standard Megolm key export format.
     65 type exportedSessionEntry struct {
     66 	Algorithm                    string            `json:"algorithm"`
     67 	ForwardingCurve25519KeyChain []string          `json:"forwarding_curve25519_key_chain"`
     68 	RoomID                       string            `json:"room_id"`
     69 	SenderClaimedKeys            map[string]string `json:"sender_claimed_keys"`
     70 	SenderKey                    string            `json:"sender_key"`
     71 	SessionID                    string            `json:"session_id"`
     72 	SessionKey                   string            `json:"session_key"`
     73 }
     74 
     75 // megolmSessions maps session IDs to live inbound sessions ready to decrypt.
     76 type megolmSessions map[id.SessionID]*session.MegolmInboundSession
     77 
     78 // ─────────────────────────────────────────────────────────────────────────────
     79 // Key-backup fetch and export
     80 // ─────────────────────────────────────────────────────────────────────────────
     81 
     82 // fetchAndExportKeyBackup derives the Megolm backup key from SSSS, downloads
     83 // all room sessions from the server key backup, decrypts them, uploads a JSON
     84 // session dump and standard key-export file to S3, and returns an in-memory
     85 // session map for use during history pagination.
     86 func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recoveryKeyStr, prefix string) (megolmSessions, error) {
     87 	var defaultKey defaultKeyEventContent
     88 	if err := getAccountData(ctx, client, "m.secret_storage.default_key", &defaultKey); err != nil {
     89 		return nil, fmt.Errorf("get default key: %w", err)
     90 	}
     91 	if defaultKey.Key == "" {
     92 		return nil, fmt.Errorf("m.secret_storage.default_key has no 'key' field")
     93 	}
     94 	keyID := defaultKey.Key
     95 	slog.Info("SSSS default key ID", "key_id", keyID)
     96 
     97 	var keyMetadata ssss.KeyMetadata
     98 	if err := getAccountData(ctx, client, "m.secret_storage.key."+keyID, &keyMetadata); err != nil {
     99 		return nil, fmt.Errorf("get key metadata: %w", err)
    100 	}
    101 
    102 	// ErrUnverifiableKey means no MAC to check — the derived key is still usable.
    103 	sssKey, err := keyMetadata.VerifyRecoveryKey(keyID, recoveryKeyStr)
    104 	if err != nil && !errors.Is(err, ssss.ErrUnverifiableKey) {
    105 		return nil, fmt.Errorf("verify recovery key: %w", err)
    106 	}
    107 	slog.Info("Recovery key verified (or unverifiable but accepted)")
    108 
    109 	var backupSecret encryptedSecretContent
    110 	if err := getAccountData(ctx, client, "m.megolm_backup.v1", &backupSecret); err != nil {
    111 		return nil, fmt.Errorf("get backup secret: %w", err)
    112 	}
    113 	encData, ok := backupSecret.Encrypted[keyID]
    114 	if !ok {
    115 		return nil, fmt.Errorf("backup secret not encrypted with key %q", keyID)
    116 	}
    117 	backupKeyRaw, err := sssKey.Decrypt("m.megolm_backup.v1", encData)
    118 	if err != nil {
    119 		return nil, fmt.Errorf("decrypt backup key from SSSS: %w", err)
    120 	}
    121 
    122 	privateKeyBytes, err := decodeBackupPrivateKey(backupKeyRaw)
    123 	if err != nil {
    124 		return nil, fmt.Errorf("decode backup private key: %w", err)
    125 	}
    126 	megolmKey, err := backup.MegolmBackupKeyFromBytes(privateKeyBytes)
    127 	if err != nil {
    128 		return nil, fmt.Errorf("create megolm backup key: %w", err)
    129 	}
    130 	slog.Info("Decoded megolm backup private key from SSSS")
    131 
    132 	var backupVersion keyBackupVersionResp
    133 	if err := matrixGetJSON(ctx, client, "/_matrix/client/v3/room_keys/version", &backupVersion); err != nil {
    134 		return nil, fmt.Errorf("get backup version: %w", err)
    135 	}
    136 	slog.Info("Key backup version", "version", backupVersion.Version)
    137 
    138 	var allRooms keyBackupAllRooms
    139 	if err := matrixGetJSON(ctx, client,
    140 		"/_matrix/client/v3/room_keys/keys?version="+url.QueryEscape(backupVersion.Version),
    141 		&allRooms,
    142 	); err != nil {
    143 		return nil, fmt.Errorf("get backup keys: %w", err)
    144 	}
    145 	slog.Info("Fetched key backup", "rooms", len(allRooms.Rooms))
    146 
    147 	sessions := make(megolmSessions)
    148 	var exported []exportedSessionEntry
    149 	decOK, decFail := 0, 0
    150 	for roomID, room := range allRooms.Rooms {
    151 		for sessionID, sessionInfo := range room.Sessions {
    152 			var encSD backup.EncryptedSessionData[backup.MegolmSessionData]
    153 			if err := json.Unmarshal(sessionInfo.SessionData, &encSD); err != nil {
    154 				slog.Warn("Failed to parse session_data",
    155 					"room_id", roomID, "session_id", sessionID, "error", err)
    156 				decFail++
    157 				continue
    158 			}
    159 			sessionData, err := encSD.Decrypt(megolmKey)
    160 			if err != nil {
    161 				slog.Warn("Failed to decrypt session",
    162 					"room_id", roomID, "session_id", sessionID, "error", err)
    163 				decFail++
    164 				continue
    165 			}
    166 			if sess, err := session.NewMegolmInboundSessionFromExport([]byte(sessionData.SessionKey)); err == nil {
    167 				sessions[id.SessionID(sessionID)] = sess
    168 			} else {
    169 				slog.Warn("Failed to import Megolm session", "session_id", sessionID, "error", err)
    170 			}
    171 			exported = append(exported, exportedSessionEntry{
    172 				Algorithm:                    string(sessionData.Algorithm),
    173 				ForwardingCurve25519KeyChain: sessionData.ForwardingKeyChain,
    174 				RoomID:                       string(roomID),
    175 				SenderClaimedKeys:            map[string]string{"ed25519": string(sessionData.SenderClaimedKeys.Ed25519)},
    176 				SenderKey:                    string(sessionData.SenderKey),
    177 				SessionID:                    sessionID,
    178 				SessionKey:                   sessionData.SessionKey,
    179 			})
    180 			decOK++
    181 		}
    182 	}
    183 	slog.Info("Session decryption complete", "ok", decOK, "failed", decFail, "in_memory", len(sessions))
    184 
    185 	if len(exported) == 0 {
    186 		slog.Warn("No sessions decrypted — skipping key export")
    187 		return sessions, nil
    188 	}
    189 
    190 	if rawJSON, err := json.MarshalIndent(exported, "", "  "); err == nil {
    191 		_ = s3PutAge(ctx, prefix+"/backup-sessions-latest.json", rawJSON)
    192 		_ = s3PutAge(ctx, prefix+"/backup-sessions-"+dateStr+".json", rawJSON)
    193 	}
    194 
    195 	exportData, err := buildMegolmExport(exported, keyExportPass)
    196 	if err != nil {
    197 		return sessions, fmt.Errorf("build megolm export: %w", err)
    198 	}
    199 	if err := s3PutAge(ctx, prefix+"/crypto-keys-latest.bin", exportData); err != nil {
    200 		return sessions, err
    201 	}
    202 	if err := s3PutAge(ctx, prefix+"/crypto-keys-"+dateStr+".bin", exportData); err != nil {
    203 		return sessions, err
    204 	}
    205 	slog.Info("Exported E2EE keys", "sessions", len(exported), "bytes", len(exportData))
    206 	return sessions, nil
    207 }
    208 
    209 // decodeBackupPrivateKey extracts the raw 32-byte Curve25519 private key.
    210 // Tries several base64 encodings used in the wild before giving up.
    211 func decodeBackupPrivateKey(raw []byte) ([]byte, error) {
    212 	if len(raw) == 32 {
    213 		return raw, nil
    214 	}
    215 	s := strings.TrimSpace(string(raw))
    216 	for _, enc := range []*base64.Encoding{
    217 		base64.RawStdEncoding,
    218 		base64.StdEncoding,
    219 		base64.RawURLEncoding,
    220 		base64.URLEncoding,
    221 	} {
    222 		if b, err := enc.DecodeString(s); err == nil && len(b) == 32 {
    223 			return b, nil
    224 		}
    225 	}
    226 	return nil, fmt.Errorf("cannot decode %d-byte value as a 32-byte Curve25519 private key", len(raw))
    227 }
    228 
    229 // ─────────────────────────────────────────────────────────────────────────────
    230 // Event decryption
    231 // ─────────────────────────────────────────────────────────────────────────────
    232 
    233 // tryDecryptEvent attempts in-place Megolm decryption of an m.room.encrypted
    234 // event. Returns the event unchanged if decryption is not possible.
    235 func tryDecryptEvent(ev *event.Event, sessions megolmSessions) *event.Event {
    236 	if ev.Type != event.EventEncrypted || sessions == nil {
    237 		return ev
    238 	}
    239 	// Events from client.Messages() only have VeryRaw set; ParseRaw must be
    240 	// called before the typed helpers (AsEncrypted etc.) work.
    241 	if ev.Content.Parsed == nil {
    242 		_ = ev.Content.ParseRaw(ev.Type)
    243 	}
    244 	content := ev.Content.AsEncrypted()
    245 	if content.Algorithm != id.AlgorithmMegolmV1 {
    246 		return ev
    247 	}
    248 	sess, ok := sessions[content.SessionID]
    249 	if !ok {
    250 		return ev
    251 	}
    252 	plaintext, _, err := sess.Decrypt(content.MegolmCiphertext)
    253 	if err != nil {
    254 		slog.Debug("Megolm decrypt failed", "session_id", content.SessionID, "error", err)
    255 		return ev
    256 	}
    257 	var inner struct {
    258 		Type    string          `json:"type"`
    259 		Content json.RawMessage `json:"content"`
    260 	}
    261 	if err := json.Unmarshal(plaintext, &inner); err != nil {
    262 		return ev
    263 	}
    264 	decrypted := *ev
    265 	decrypted.Type = event.NewEventType(inner.Type)
    266 	decrypted.Content = event.Content{VeryRaw: inner.Content}
    267 	return &decrypted
    268 }
    269 
    270 // ─────────────────────────────────────────────────────────────────────────────
    271 // Megolm key export (spec §14.4 — PBKDF2-SHA512 + AES-256-CTR + HMAC-SHA256)
    272 // ─────────────────────────────────────────────────────────────────────────────
    273 
    274 func buildMegolmExport(sessions []exportedSessionEntry, passphrase string) ([]byte, error) {
    275 	salt := make([]byte, 16)
    276 	if _, err := rand.Read(salt); err != nil {
    277 		return nil, err
    278 	}
    279 	ivBytes := make([]byte, 16)
    280 	if _, err := rand.Read(ivBytes); err != nil {
    281 		return nil, err
    282 	}
    283 	ivBytes[0] &= 0x7F // spec: highest bit of IV must be 0
    284 
    285 	const iterations = 100_000
    286 	keyMaterial := pbkdf2.Key([]byte(passphrase), salt, iterations, 64, sha512.New)
    287 	aesKey := keyMaterial[:32]
    288 	hmacKey := keyMaterial[32:]
    289 
    290 	plaintext, err := json.Marshal(sessions)
    291 	if err != nil {
    292 		return nil, err
    293 	}
    294 	block, err := aes.NewCipher(aesKey)
    295 	if err != nil {
    296 		return nil, err
    297 	}
    298 	ciphertext := make([]byte, len(plaintext))
    299 	cipher.NewCTR(block, ivBytes).XORKeyStream(ciphertext, plaintext)
    300 
    301 	iterBuf := make([]byte, 4)
    302 	binary.BigEndian.PutUint32(iterBuf, uint32(iterations))
    303 
    304 	payload := []byte{0x01}
    305 	payload = append(payload, salt...)
    306 	payload = append(payload, ivBytes...)
    307 	payload = append(payload, iterBuf...)
    308 	payload = append(payload, ciphertext...)
    309 
    310 	mac := hmac.New(sha256.New, hmacKey)
    311 	mac.Write(payload)
    312 	payload = append(payload, mac.Sum(nil)...)
    313 
    314 	encoded := base64.StdEncoding.EncodeToString(payload)
    315 	var sb strings.Builder
    316 	sb.WriteString("-----BEGIN MEGOLM SESSION DATA-----\n")
    317 	for i := 0; i < len(encoded); i += 76 {
    318 		end := i + 76
    319 		if end > len(encoded) {
    320 			end = len(encoded)
    321 		}
    322 		sb.WriteString(encoded[i:end])
    323 		sb.WriteByte('\n')
    324 	}
    325 	sb.WriteString("-----END MEGOLM SESSION DATA-----\n")
    326 	return []byte(sb.String()), nil
    327 }