cluster

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

main.go (26416B)


      1 // recovery is a local utility for retrieving, decrypting, and displaying
      2 // age-encrypted backup files from S3.  It does more than just decrypt: it
      3 // reconstructs full conversation history, downloads backed-up media, and
      4 // shows account data summaries.
      5 //
      6 // Credentials are read from the Kubernetes Secret YAML at
      7 // ../secret.yaml (relative to the working directory) — the same file that the
      8 // CronJob references.  No env vars or extra config needed.
      9 //
     10 // Usage (run from the backup-tool/ directory):
     11 //
     12 //	go run ./recovery/ -identity ~/age.key -prefix mtrnord
     13 //	go run ./recovery/ -identity ~/age.key -prefix mtrnord -room '!roomid:server'
     14 //	go run ./recovery/ -identity ~/age.key -prefix mtrnord -all -out ./decrypted
     15 //
     16 // Optional overrides:
     17 //
     18 //	-secret   path to secret.yaml (default: ../secret.yaml)
     19 //	-endpoint S3 endpoint          (default: hel1.your-objectstorage.com)
     20 //	-region   S3 region            (default: hel1)
     21 //	-bucket   S3 bucket name       (default: midnightthoughts-matrix-backup)
     22 //	-out      output directory     (default: ./decrypted)
     23 package main
     24 
     25 import (
     26 	"bufio"
     27 	"bytes"
     28 	"context"
     29 	"crypto/aes"
     30 	"crypto/cipher"
     31 	"crypto/sha256"
     32 	"encoding/base64"
     33 	"encoding/json"
     34 	"errors"
     35 	"flag"
     36 	"fmt"
     37 	"io"
     38 	"log/slog"
     39 	"os"
     40 	"path/filepath"
     41 	"sort"
     42 	"strings"
     43 	"time"
     44 
     45 	"filippo.io/age"
     46 	"github.com/aws/aws-sdk-go-v2/aws"
     47 	awsconfig "github.com/aws/aws-sdk-go-v2/config"
     48 	"github.com/aws/aws-sdk-go-v2/credentials"
     49 	"github.com/aws/aws-sdk-go-v2/service/s3"
     50 	s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
     51 	"gopkg.in/yaml.v3"
     52 )
     53 
     54 // ─────────────────────────────────────────────────────────────────────────────
     55 // CLI flags
     56 // ─────────────────────────────────────────────────────────────────────────────
     57 
     58 var (
     59 	flagIdentity = flag.String("identity", "", "path to age identity file (private key) [required]")
     60 	flagPrefix   = flag.String("prefix", "", "account prefix: mtrnord or lexi [required]")
     61 	flagSecret   = flag.String("secret", "../secret.yaml", "path to secret.yaml")
     62 	flagEndpoint = flag.String("endpoint", "hel1.your-objectstorage.com", "S3 endpoint")
     63 	flagRegion   = flag.String("region", "hel1", "S3 region")
     64 	flagBucket   = flag.String("bucket", "midnightthoughts-matrix-backup", "S3 bucket")
     65 	flagRoom     = flag.String("room", "", "room ID to decrypt (empty = list rooms only)")
     66 	flagOut      = flag.String("out", "./decrypted", "output directory for decrypted files")
     67 	flagAll      = flag.Bool("all", false, "dump history for ALL rooms")
     68 )
     69 
     70 // ─────────────────────────────────────────────────────────────────────────────
     71 // Secret YAML parser
     72 // ─────────────────────────────────────────────────────────────────────────────
     73 
     74 // parseSecretYAML extracts the stringData map from a Kubernetes Secret YAML.
     75 func parseSecretYAML(path string) (map[string]string, error) {
     76 	f, err := os.Open(path)
     77 	if err != nil {
     78 		return nil, fmt.Errorf("open secret file: %w", err)
     79 	}
     80 	defer f.Close()
     81 
     82 	var secret struct {
     83 		Sops       map[string]any    `yaml:"sops"`
     84 		StringData map[string]string `yaml:"stringData"`
     85 	}
     86 	if err := yaml.NewDecoder(f).Decode(&secret); err != nil {
     87 		return nil, fmt.Errorf("decode secret yaml: %w", err)
     88 	}
     89 	if secret.Sops != nil {
     90 		return nil, fmt.Errorf("secret.yaml is SOPS-encrypted — run `sops -d %s > /tmp/secret-plain.yaml` and point -secret at the plaintext file", path)
     91 	}
     92 	if secret.StringData == nil {
     93 		return nil, fmt.Errorf("secret.yaml has no stringData section")
     94 	}
     95 	return secret.StringData, nil
     96 }
     97 
     98 // ─────────────────────────────────────────────────────────────────────────────
     99 // S3
    100 // ─────────────────────────────────────────────────────────────────────────────
    101 
    102 var (
    103 	s3c          *s3.Client
    104 	s3BucketName string
    105 )
    106 
    107 func initS3(ctx context.Context, endpoint, region, bucket, accessKey, secretKey string) error {
    108 	s3BucketName = bucket
    109 	cfg, err := awsconfig.LoadDefaultConfig(ctx,
    110 		awsconfig.WithRegion(region),
    111 		awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
    112 	)
    113 	if err != nil {
    114 		return err
    115 	}
    116 	ep := "https://" + endpoint
    117 	s3c = s3.NewFromConfig(cfg, func(o *s3.Options) {
    118 		o.UsePathStyle = true
    119 		o.BaseEndpoint = &ep
    120 	})
    121 	return nil
    122 }
    123 
    124 func s3GetBytes(ctx context.Context, key string) ([]byte, error) {
    125 	out, err := s3c.GetObject(ctx, &s3.GetObjectInput{
    126 		Bucket: aws.String(s3BucketName),
    127 		Key:    aws.String(key),
    128 	})
    129 	if err != nil {
    130 		var nsk *s3types.NoSuchKey
    131 		if errors.As(err, &nsk) {
    132 			return nil, nil
    133 		}
    134 		return nil, err
    135 	}
    136 	defer out.Body.Close()
    137 	return io.ReadAll(out.Body)
    138 }
    139 
    140 func listPrefix(ctx context.Context, prefix string) ([]string, error) {
    141 	var keys []string
    142 	paginator := s3.NewListObjectsV2Paginator(s3c, &s3.ListObjectsV2Input{
    143 		Bucket: aws.String(s3BucketName),
    144 		Prefix: aws.String(prefix),
    145 	})
    146 	for paginator.HasMorePages() {
    147 		page, err := paginator.NextPage(ctx)
    148 		if err != nil {
    149 			return nil, err
    150 		}
    151 		for _, obj := range page.Contents {
    152 			keys = append(keys, *obj.Key)
    153 		}
    154 	}
    155 	return keys, nil
    156 }
    157 
    158 // ─────────────────────────────────────────────────────────────────────────────
    159 // Age decryption
    160 // ─────────────────────────────────────────────────────────────────────────────
    161 
    162 var ageIdentities []age.Identity
    163 
    164 func loadIdentities(path string) error {
    165 	f, err := os.Open(path)
    166 	if err != nil {
    167 		return fmt.Errorf("open identity file: %w", err)
    168 	}
    169 	defer f.Close()
    170 	ids, err := age.ParseIdentities(f)
    171 	if err != nil {
    172 		return fmt.Errorf("parse identities: %w", err)
    173 	}
    174 	ageIdentities = ids
    175 	return nil
    176 }
    177 
    178 func ageDecrypt(data []byte) ([]byte, error) {
    179 	r, err := age.Decrypt(bytes.NewReader(data), ageIdentities...)
    180 	if err != nil {
    181 		return nil, err
    182 	}
    183 	return io.ReadAll(r)
    184 }
    185 
    186 // tryDecodeBase64 attempts to decode a base64 string using all four standard
    187 // variants (raw/padded × URL-safe/standard) and returns the first that succeeds.
    188 // Matrix encrypted-media fields are specified as URL-safe unpadded base64, but
    189 // older clients used standard base64 with + and / characters.
    190 func tryDecodeBase64(s string) ([]byte, error) {
    191 	for _, enc := range []*base64.Encoding{
    192 		base64.RawURLEncoding,
    193 		base64.URLEncoding,
    194 		base64.RawStdEncoding,
    195 		base64.StdEncoding,
    196 	} {
    197 		if b, err := enc.DecodeString(s); err == nil {
    198 			return b, nil
    199 		}
    200 	}
    201 	return nil, fmt.Errorf("cannot base64-decode %q", s)
    202 }
    203 
    204 func getDecryptedAge(ctx context.Context, key string) ([]byte, error) {
    205 	raw, err := s3GetBytes(ctx, key)
    206 	if err != nil {
    207 		return nil, err
    208 	}
    209 	if raw == nil {
    210 		return nil, fmt.Errorf("key not found: %s", key)
    211 	}
    212 	return ageDecrypt(raw)
    213 }
    214 
    215 // ─────────────────────────────────────────────────────────────────────────────
    216 // Room list
    217 // ─────────────────────────────────────────────────────────────────────────────
    218 
    219 type roomEntry struct {
    220 	RoomID         string   `json:"room_id"`
    221 	Name           string   `json:"name"`
    222 	Type           string   `json:"type"`
    223 	Aliases        []string `json:"aliases"`
    224 	CanonicalAlias string   `json:"canonical_alias,omitempty"`
    225 	MemberCount    int      `json:"member_count"`
    226 	Encrypted      bool     `json:"encrypted"`
    227 	ViaServers     []string `json:"via_servers,omitempty"`
    228 }
    229 
    230 func listRooms(ctx context.Context, prefix string) ([]roomEntry, error) {
    231 	data, err := getDecryptedAge(ctx, prefix+"/rooms-latest.json.age")
    232 	if err != nil {
    233 		return nil, fmt.Errorf("fetch room list: %w", err)
    234 	}
    235 	var rooms []roomEntry
    236 	if err := json.Unmarshal(data, &rooms); err != nil {
    237 		return nil, fmt.Errorf("parse room list: %w", err)
    238 	}
    239 	return rooms, nil
    240 }
    241 
    242 // ─────────────────────────────────────────────────────────────────────────────
    243 // Room account data (tags)
    244 // ─────────────────────────────────────────────────────────────────────────────
    245 
    246 // loadRoomAccountData returns the per-room account data map, or nil if absent.
    247 func loadRoomAccountData(ctx context.Context, prefix string) map[string]map[string]json.RawMessage {
    248 	raw, err := s3GetBytes(ctx, prefix+"/room-account-data-latest.json.age")
    249 	if err != nil || raw == nil {
    250 		return nil
    251 	}
    252 	plain, err := ageDecrypt(raw)
    253 	if err != nil {
    254 		slog.Warn("Failed to decrypt room account data", "error", err)
    255 		return nil
    256 	}
    257 	var result map[string]map[string]json.RawMessage
    258 	if err := json.Unmarshal(plain, &result); err != nil {
    259 		slog.Warn("Failed to parse room account data", "error", err)
    260 		return nil
    261 	}
    262 	return result
    263 }
    264 
    265 // roomTagLabel returns a short label for known room tags (empty string if none).
    266 func roomTagLabel(roomID string, roomAccountData map[string]map[string]json.RawMessage) string {
    267 	if roomAccountData == nil {
    268 		return ""
    269 	}
    270 	rd, ok := roomAccountData[roomID]
    271 	if !ok {
    272 		return ""
    273 	}
    274 	tagRaw, ok := rd["m.tag"]
    275 	if !ok {
    276 		return ""
    277 	}
    278 	var tags struct {
    279 		Tags map[string]json.RawMessage `json:"tags"`
    280 	}
    281 	if json.Unmarshal(tagRaw, &tags) != nil {
    282 		return ""
    283 	}
    284 	if _, ok := tags.Tags["m.favourite"]; ok {
    285 		return " [fav]"
    286 	}
    287 	if _, ok := tags.Tags["m.lowpriority"]; ok {
    288 		return " [low]"
    289 	}
    290 	return ""
    291 }
    292 
    293 // ─────────────────────────────────────────────────────────────────────────────
    294 // Account data summary
    295 // ─────────────────────────────────────────────────────────────────────────────
    296 
    297 // printAccountDataSummary prints a brief summary of global account data.
    298 func printAccountDataSummary(ctx context.Context, prefix string) {
    299 	raw, err := s3GetBytes(ctx, prefix+"/account-data-latest.json.age")
    300 	if err != nil || raw == nil {
    301 		return
    302 	}
    303 	plain, err := ageDecrypt(raw)
    304 	if err != nil {
    305 		return
    306 	}
    307 	var data map[string]json.RawMessage
    308 	if err := json.Unmarshal(plain, &data); err != nil {
    309 		return
    310 	}
    311 
    312 	var pushRuleCount int
    313 	if pr, ok := data["m.push_rules"]; ok {
    314 		var rules struct {
    315 			Global map[string][]json.RawMessage `json:"global"`
    316 		}
    317 		if json.Unmarshal(pr, &rules) == nil {
    318 			for _, list := range rules.Global {
    319 				pushRuleCount += len(list)
    320 			}
    321 		}
    322 	}
    323 
    324 	var ignoredCount int
    325 	if il, ok := data["m.ignored_user_list"]; ok {
    326 		var ignored struct {
    327 			IgnoredUsers map[string]json.RawMessage `json:"ignored_users"`
    328 		}
    329 		if json.Unmarshal(il, &ignored) == nil {
    330 			ignoredCount = len(ignored.IgnoredUsers)
    331 		}
    332 	}
    333 
    334 	if pushRuleCount > 0 || ignoredCount > 0 {
    335 		fmt.Printf("Account data: %d push rules, %d ignored users\n\n", pushRuleCount, ignoredCount)
    336 	}
    337 }
    338 
    339 // ─────────────────────────────────────────────────────────────────────────────
    340 // History events
    341 // ─────────────────────────────────────────────────────────────────────────────
    342 
    343 type historyEvent struct {
    344 	EventID   string          `json:"event_id"`
    345 	Sender    string          `json:"sender"`
    346 	Type      string          `json:"type"`
    347 	Timestamp int64           `json:"origin_server_ts"`
    348 	Content   json.RawMessage `json:"content"`
    349 	Encrypted bool            `json:"encrypted,omitempty"`
    350 }
    351 
    352 // displayNameFor returns the best human-readable name for a sender.
    353 func displayNameFor(userID string, displayNames map[string]string) string {
    354 	if n, ok := displayNames[userID]; ok && n != "" {
    355 		return n
    356 	}
    357 	if len(userID) > 1 && userID[0] == '@' {
    358 		if i := strings.Index(userID[1:], ":"); i >= 0 {
    359 			return userID[1 : i+1]
    360 		}
    361 	}
    362 	return userID
    363 }
    364 
    365 // prettyContent returns a readable one-liner for common event types.
    366 func prettyContent(evType string, rawContent json.RawMessage) string {
    367 	var c map[string]json.RawMessage
    368 	if json.Unmarshal(rawContent, &c) != nil {
    369 		return string(rawContent)
    370 	}
    371 	switch evType {
    372 	case "m.room.message":
    373 		var body, msgtype string
    374 		json.Unmarshal(c["body"], &body)       //nolint:errcheck
    375 		json.Unmarshal(c["msgtype"], &msgtype) //nolint:errcheck
    376 		switch msgtype {
    377 		case "m.image":
    378 			return "[image: " + body + "]"
    379 		case "m.file":
    380 			return "[file: " + body + "]"
    381 		case "m.video":
    382 			return "[video: " + body + "]"
    383 		case "m.audio":
    384 			return "[audio: " + body + "]"
    385 		default:
    386 			return body
    387 		}
    388 	case "m.room.member":
    389 		var membership, dn string
    390 		json.Unmarshal(c["membership"], &membership) //nolint:errcheck
    391 		json.Unmarshal(c["displayname"], &dn)        //nolint:errcheck
    392 		if dn != "" {
    393 			return fmt.Sprintf("[membership: %s, displayname: %s]", membership, dn)
    394 		}
    395 		return fmt.Sprintf("[membership: %s]", membership)
    396 	case "m.reaction":
    397 		var rel map[string]json.RawMessage
    398 		if raw, ok := c["m.relates_to"]; ok {
    399 			if json.Unmarshal(raw, &rel) == nil {
    400 				var key string
    401 				json.Unmarshal(rel["key"], &key) //nolint:errcheck
    402 				return "[reaction: " + key + "]"
    403 			}
    404 		}
    405 	case "m.room.encrypted":
    406 		return "[encrypted — session not available]"
    407 	}
    408 	return string(rawContent)
    409 }
    410 
    411 func dumpHistory(ctx context.Context, prefix, roomID, roomName, outDir string, roomAccountData map[string]map[string]json.RawMessage) error {
    412 	safeKey := strings.NewReplacer("/", "_", ":", "_").Replace(roomID)
    413 	histPrefix := prefix + "/history/" + safeKey + "/"
    414 
    415 	keys, err := listPrefix(ctx, histPrefix)
    416 	if err != nil {
    417 		return fmt.Errorf("list history keys: %w", err)
    418 	}
    419 	if len(keys) == 0 {
    420 		slog.Info("No history files found", "room_id", roomID)
    421 		return nil
    422 	}
    423 
    424 	var allEvents []historyEvent
    425 	displayNames := make(map[string]string)
    426 
    427 	for _, key := range keys {
    428 		if !strings.HasSuffix(key, ".age") {
    429 			continue
    430 		}
    431 		data, err := getDecryptedAge(ctx, key)
    432 		if err != nil {
    433 			slog.Warn("Failed to decrypt", "key", key, "error", err)
    434 			continue
    435 		}
    436 		sc := bufio.NewScanner(bytes.NewReader(data))
    437 		for sc.Scan() {
    438 			line := sc.Bytes()
    439 			if len(line) == 0 {
    440 				continue
    441 			}
    442 			var ev historyEvent
    443 			if err := json.Unmarshal(line, &ev); err != nil {
    444 				slog.Warn("Failed to parse event line", "error", err)
    445 				continue
    446 			}
    447 			allEvents = append(allEvents, ev)
    448 			if ev.Type == "m.room.member" {
    449 				var mc struct {
    450 					Displayname string `json:"displayname"`
    451 				}
    452 				if json.Unmarshal(ev.Content, &mc) == nil && mc.Displayname != "" {
    453 					displayNames[ev.Sender] = mc.Displayname
    454 				}
    455 			}
    456 		}
    457 		if err := sc.Err(); err != nil {
    458 			slog.Warn("Scanner error reading history file", "key", key, "error", err)
    459 		}
    460 	}
    461 
    462 	if len(allEvents) == 0 {
    463 		slog.Info("No events after parsing", "room_id", roomID)
    464 		return nil
    465 	}
    466 
    467 	// Sort by timestamp.
    468 	sort.Slice(allEvents, func(i, j int) bool {
    469 		return allEvents[i].Timestamp < allEvents[j].Timestamp
    470 	})
    471 
    472 	// Deduplicate by event ID.
    473 	seen := make(map[string]struct{}, len(allEvents))
    474 	deduped := allEvents[:0]
    475 	for _, ev := range allEvents {
    476 		if _, dup := seen[ev.EventID]; dup {
    477 			continue
    478 		}
    479 		seen[ev.EventID] = struct{}{}
    480 		deduped = append(deduped, ev)
    481 	}
    482 	allEvents = deduped
    483 
    484 	roomDir := filepath.Join(outDir, safeKey)
    485 	if err := os.MkdirAll(roomDir, 0700); err != nil {
    486 		return err
    487 	}
    488 
    489 	// Write plain-text log with mode 0600 (contains decrypted messages).
    490 	txtPath := filepath.Join(roomDir, "history.txt")
    491 	tf, err := os.OpenFile(txtPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
    492 	if err != nil {
    493 		return err
    494 	}
    495 	defer tf.Close()
    496 
    497 	fmt.Fprintf(tf, "Room: %s (%s)\n", roomName, roomID)
    498 	fmt.Fprintf(tf, "Events: %d\n\n", len(allEvents))
    499 	for _, ev := range allEvents {
    500 		ts := time.UnixMilli(ev.Timestamp).UTC().Format("2006-01-02 15:04:05")
    501 		sender := displayNameFor(ev.Sender, displayNames)
    502 		content := prettyContent(ev.Type, ev.Content)
    503 		if ev.Encrypted {
    504 			fmt.Fprintf(tf, "[%s] <%s> [encrypted] %s\n", ts, sender, content)
    505 		} else {
    506 			fmt.Fprintf(tf, "[%s] <%s> %s\n", ts, sender, content)
    507 		}
    508 	}
    509 
    510 	// Write raw JSONL with mode 0600.
    511 	jsonlPath := filepath.Join(roomDir, "history.jsonl")
    512 	jf, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
    513 	if err != nil {
    514 		return err
    515 	}
    516 	defer jf.Close()
    517 	enc := json.NewEncoder(jf)
    518 	for _, ev := range allEvents {
    519 		if err := enc.Encode(ev); err != nil {
    520 			slog.Warn("Failed to encode event to JSONL, skipping", "event_id", ev.EventID, "error", err)
    521 		}
    522 	}
    523 
    524 	// Write room tags if present.
    525 	if roomAccountData != nil {
    526 		if rd, ok := roomAccountData[roomID]; ok {
    527 			if tagRaw, ok := rd["m.tag"]; ok {
    528 				tagPath := filepath.Join(roomDir, "room-tags.json")
    529 				if data, err := json.MarshalIndent(tagRaw, "", "  "); err == nil {
    530 					_ = os.WriteFile(tagPath, data, 0600)
    531 				}
    532 			}
    533 		}
    534 	}
    535 
    536 	slog.Info("Dumped room history",
    537 		"room", roomName,
    538 		"events", len(allEvents),
    539 		"txt", txtPath,
    540 		"jsonl", jsonlPath,
    541 	)
    542 
    543 	downloadMedia(ctx, prefix, allEvents, roomDir)
    544 	return nil
    545 }
    546 
    547 // downloadMedia saves backed-up media files for the given events into a
    548 // "media/" subdirectory inside roomDir.  Files already present are skipped.
    549 func downloadMedia(ctx context.Context, prefix string, events []historyEvent, roomDir string) {
    550 	mediaDir := filepath.Join(roomDir, "media")
    551 
    552 	mediaMsgTypes := map[string]bool{
    553 		"m.image": true, "m.file": true, "m.video": true,
    554 		"m.audio": true, "m.sticker": true,
    555 	}
    556 
    557 	downloaded, skipped, missing := 0, 0, 0
    558 
    559 	for _, ev := range events {
    560 		if ev.Type != "m.room.message" && ev.Type != "m.sticker" {
    561 			continue
    562 		}
    563 
    564 		var c struct {
    565 			MsgType string `json:"msgtype"`
    566 			Body    string `json:"body"`
    567 			URL     string `json:"url"`
    568 			File    *struct {
    569 				URL string `json:"url"`
    570 				Key struct {
    571 					K string `json:"k"`
    572 				} `json:"key"`
    573 				IV     string            `json:"iv"`
    574 				Hashes map[string]string `json:"hashes"`
    575 			} `json:"file"`
    576 		}
    577 		if json.Unmarshal(ev.Content, &c) != nil {
    578 			continue
    579 		}
    580 		if ev.Type == "m.room.message" && !mediaMsgTypes[c.MsgType] {
    581 			continue
    582 		}
    583 
    584 		mxcURL := c.URL
    585 		encrypted := false
    586 		if c.File != nil && c.File.URL != "" {
    587 			mxcURL = c.File.URL
    588 			encrypted = true
    589 		}
    590 		if mxcURL == "" || !strings.HasPrefix(mxcURL, "mxc://") {
    591 			continue
    592 		}
    593 
    594 		rest := mxcURL[len("mxc://"):]
    595 		slash := strings.Index(rest, "/")
    596 		if slash < 0 {
    597 			continue
    598 		}
    599 		server, mediaID := rest[:slash], rest[slash+1:]
    600 		if server == "" || mediaID == "" {
    601 			continue
    602 		}
    603 
    604 		s3Key := prefix + "/media/" + server + "/" + mediaID
    605 		data, err := s3GetBytes(ctx, s3Key)
    606 		if err != nil {
    607 			slog.Warn("Media fetch error", "mxc", mxcURL, "error", err)
    608 			missing++
    609 			continue
    610 		}
    611 		if data == nil {
    612 			slog.Debug("Media not in backup", "mxc", mxcURL)
    613 			missing++
    614 			continue
    615 		}
    616 
    617 		// Verify SHA-256 hash before decrypting to detect corruption/tampering.
    618 		if encrypted && c.File != nil {
    619 			if sha, ok := c.File.Hashes["sha256"]; ok {
    620 				sum := sha256.Sum256(data)
    621 				if base64.RawStdEncoding.EncodeToString(sum[:]) != sha {
    622 					slog.Warn("Media hash mismatch, skipping", "mxc", mxcURL)
    623 					missing++
    624 					continue
    625 				}
    626 			}
    627 
    628 			// Key and IV may use URL-safe or standard base64, with or without
    629 			// padding, depending on which client sent the message.
    630 			keyBytes, err1 := tryDecodeBase64(c.File.Key.K)
    631 			ivBytes, err2 := tryDecodeBase64(c.File.IV)
    632 			if err1 != nil || err2 != nil || len(keyBytes) != 32 {
    633 				slog.Warn("Bad key/IV for encrypted media", "mxc", mxcURL,
    634 					"key_err", err1, "iv_err", err2, "key_len", len(keyBytes), "iv_len", len(ivBytes))
    635 				missing++
    636 				continue
    637 			}
    638 			// The spec stores the 64-bit random IV prefix; the full 128-bit
    639 			// AES-CTR counter block is IV || zeros (counter starts at 0).
    640 			// Some clients store all 16 bytes directly — accept both.
    641 			if len(ivBytes) == 8 {
    642 				full := make([]byte, 16)
    643 				copy(full, ivBytes)
    644 				ivBytes = full
    645 			}
    646 			if len(ivBytes) != 16 {
    647 				slog.Warn("Bad IV length for encrypted media", "mxc", mxcURL, "iv_len", len(ivBytes))
    648 				missing++
    649 				continue
    650 			}
    651 			block, err := aes.NewCipher(keyBytes)
    652 			if err != nil {
    653 				slog.Warn("AES init failed", "error", err)
    654 				missing++
    655 				continue
    656 			}
    657 			plain := make([]byte, len(data))
    658 			cipher.NewCTR(block, ivBytes).XORKeyStream(plain, data)
    659 			data = plain
    660 		}
    661 
    662 		// Sanitise the filename: strip path-separator characters and control chars,
    663 		// then verify the resolved path stays within mediaDir.
    664 		safeName := strings.Map(func(r rune) rune {
    665 			if r < 0x20 || strings.ContainsRune(`/\:*?"<>|`, r) {
    666 				return '_'
    667 			}
    668 			return r
    669 		}, c.Body)
    670 		if safeName == "" {
    671 			safeName = mediaID
    672 		}
    673 		outName := mediaID[:8] + "_" + safeName
    674 		outPath := filepath.Join(mediaDir, outName)
    675 
    676 		// Prevent path traversal: ensure the resolved path is under mediaDir.
    677 		cleanMedia := filepath.Clean(mediaDir) + string(os.PathSeparator)
    678 		if !strings.HasPrefix(filepath.Clean(outPath)+string(os.PathSeparator), cleanMedia) {
    679 			slog.Warn("Skipping unsafe media path", "body", c.Body, "mxc", mxcURL)
    680 			missing++
    681 			continue
    682 		}
    683 
    684 		if _, err := os.Stat(outPath); err == nil {
    685 			skipped++
    686 			continue
    687 		}
    688 		if err := os.MkdirAll(mediaDir, 0700); err != nil {
    689 			slog.Warn("Cannot create media dir", "error", err)
    690 			return
    691 		}
    692 		if err := os.WriteFile(outPath, data, 0600); err != nil {
    693 			slog.Warn("Cannot write media file", "path", outPath, "error", err)
    694 			continue
    695 		}
    696 		downloaded++
    697 	}
    698 
    699 	slog.Info("Media download complete",
    700 		"downloaded", downloaded,
    701 		"skipped", skipped,
    702 		"not_in_backup", missing,
    703 		"dir", mediaDir,
    704 	)
    705 }
    706 
    707 // ─────────────────────────────────────────────────────────────────────────────
    708 // Main
    709 // ─────────────────────────────────────────────────────────────────────────────
    710 
    711 func main() {
    712 	flag.Parse()
    713 
    714 	if *flagIdentity == "" || *flagPrefix == "" {
    715 		fmt.Fprintln(os.Stderr, "Usage: go run ./recovery/ -identity <age-key-file> -prefix <mtrnord|lexi> [-room <room-id>] [-all] [-out ./decrypted]")
    716 		os.Exit(1)
    717 	}
    718 
    719 	slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
    720 
    721 	ctx := context.Background()
    722 
    723 	secrets, err := parseSecretYAML(*flagSecret)
    724 	if err != nil {
    725 		slog.Error("Failed to parse secret.yaml", "path", *flagSecret, "error", err)
    726 		os.Exit(1)
    727 	}
    728 	accessKey := secrets["s3_access_key"]
    729 	secretKey := secrets["s3_secret_key"]
    730 	if accessKey == "" || secretKey == "" {
    731 		slog.Error("secret.yaml missing s3_access_key or s3_secret_key", "path", *flagSecret)
    732 		os.Exit(1)
    733 	}
    734 	slog.Info("Loaded S3 credentials from secret.yaml")
    735 
    736 	if err := loadIdentities(*flagIdentity); err != nil {
    737 		slog.Error("Failed to load age identity", "error", err)
    738 		os.Exit(1)
    739 	}
    740 
    741 	if err := initS3(ctx, *flagEndpoint, *flagRegion, *flagBucket, accessKey, secretKey); err != nil {
    742 		slog.Error("Failed to init S3", "error", err)
    743 		os.Exit(1)
    744 	}
    745 
    746 	rooms, err := listRooms(ctx, *flagPrefix)
    747 	if err != nil {
    748 		slog.Error("Failed to list rooms", "error", err)
    749 		os.Exit(1)
    750 	}
    751 
    752 	roomAccountData := loadRoomAccountData(ctx, *flagPrefix)
    753 	printAccountDataSummary(ctx, *flagPrefix)
    754 
    755 	fmt.Printf("Rooms for prefix %q (%d total):\n\n", *flagPrefix, len(rooms))
    756 	fmt.Printf("%-55s %-7s %-7s %s\n", "ROOM ID", "TYPE", "MEMBERS", "NAME")
    757 	fmt.Printf("%s\n", strings.Repeat("-", 110))
    758 	for _, r := range rooms {
    759 		enc := ""
    760 		if r.Encrypted {
    761 			enc = " [E2EE]"
    762 		}
    763 		tag := roomTagLabel(r.RoomID, roomAccountData)
    764 		fmt.Printf("%-55s %-7s %-7d %s%s%s\n", r.RoomID, r.Type, r.MemberCount, r.Name, enc, tag)
    765 		if r.CanonicalAlias != "" {
    766 			fmt.Printf("  alias: %s\n", r.CanonicalAlias)
    767 		} else if len(r.Aliases) > 0 {
    768 			fmt.Printf("  alias: %s\n", r.Aliases[0])
    769 		}
    770 		if len(r.ViaServers) > 0 {
    771 			fmt.Printf("  via:   %s\n", strings.Join(r.ViaServers, ", "))
    772 		}
    773 	}
    774 	fmt.Println()
    775 
    776 	if !*flagAll && *flagRoom == "" {
    777 		return
    778 	}
    779 
    780 	if err := os.MkdirAll(*flagOut, 0700); err != nil {
    781 		slog.Error("Failed to create output dir", "error", err)
    782 		os.Exit(1)
    783 	}
    784 
    785 	if *flagAll {
    786 		for _, r := range rooms {
    787 			if err := dumpHistory(ctx, *flagPrefix, r.RoomID, r.Name, *flagOut, roomAccountData); err != nil {
    788 				slog.Warn("Failed to dump room", "room_id", r.RoomID, "error", err)
    789 			}
    790 		}
    791 	} else {
    792 		name := *flagRoom
    793 		for _, r := range rooms {
    794 			if r.RoomID == *flagRoom {
    795 				name = r.Name
    796 				break
    797 			}
    798 		}
    799 		if err := dumpHistory(ctx, *flagPrefix, *flagRoom, name, *flagOut, roomAccountData); err != nil {
    800 			slog.Error("Failed to dump room", "room_id", *flagRoom, "error", err)
    801 			os.Exit(1)
    802 		}
    803 	}
    804 }