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 }