commit 2f5acc36d3a79e0b8d82341994274fc1c62ee2f0
parent bdcff1962b0bead4af6a601de8d29a46c4d3bb5d
Author: MTRNord <MTRNord@users.noreply.github.com>
Date: Sat, 4 Apr 2026 02:17:25 +0200
improve the backup tool
Signed-off-by: MTRNord <MTRNord@users.noreply.github.com>
Diffstat:
4 files changed, 225 insertions(+), 69 deletions(-)
diff --git a/apps/talos_cluster/matrix-backup/backup-cronjob.yaml b/apps/talos_cluster/matrix-backup/backup-cronjob.yaml
@@ -80,6 +80,11 @@ spec:
secretKeyRef:
name: matrix-backup-secret
key: key_export_passphrase
+ - name: AGE_RECIPIENTS
+ valueFrom:
+ secretKeyRef:
+ name: matrix-backup-secret
+ key: age_recipients
volumeMounts:
- name: data
mountPath: /data
diff --git a/apps/talos_cluster/matrix-backup/backup-tool/go.mod b/apps/talos_cluster/matrix-backup/backup-tool/go.mod
@@ -3,6 +3,7 @@ module github.com/mtrnord/matrix-backup
go 1.25
require (
+ filippo.io/age v1.2.1
github.com/aws/aws-sdk-go-v2 v1.30.3
github.com/aws/aws-sdk-go-v2/config v1.27.26
github.com/aws/aws-sdk-go-v2/credentials v1.17.26
diff --git a/apps/talos_cluster/matrix-backup/backup-tool/main.go b/apps/talos_cluster/matrix-backup/backup-tool/main.go
@@ -2,6 +2,8 @@
// homeserver and stores them in S3. Accounts are configured via environment
// variables; see the deployment CronJob manifest for the full list.
//
+// Room history is decrypted using the Megolm sessions fetched from the
+// server-side key backup, then re-encrypted with age before uploading to S3.
// Session tokens and the device crypto store are persisted as a tarball in S3
// so incremental sync works across CronJob runs.
//
@@ -32,6 +34,7 @@ import (
"strings"
"time"
+ "filippo.io/age"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
@@ -40,6 +43,8 @@ import (
"golang.org/x/crypto/pbkdf2"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/backup"
+ _ "maunium.net/go/mautrix/crypto/goolm" // registers goolm implementations for olm package
+ "maunium.net/go/mautrix/crypto/olm"
"maunium.net/go/mautrix/crypto/ssss"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@@ -67,6 +72,7 @@ var (
s3SecretKey = mustEnv("S3_SECRET_KEY")
keyExportPass = mustEnv("KEY_EXPORT_PASSPHRASE")
dateStr = time.Now().UTC().Format("20060102")
+ runStr = time.Now().UTC().Format("20060102-150405")
accounts = []accountCfg{
{
@@ -84,6 +90,9 @@ var (
StoreDir: "/data/crypto/lexi",
},
}
+
+ // ageRecipients holds the parsed public keys used to encrypt history files.
+ ageRecipients []age.Recipient
)
func mustEnv(key string) string {
@@ -95,6 +104,55 @@ func mustEnv(key string) string {
return v
}
+// initAgeRecipients parses a comma-separated list of age public keys and
+// populates the global ageRecipients slice.
+func initAgeRecipients(s string) error {
+ for _, raw := range strings.Split(s, ",") {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ continue
+ }
+ r, err := age.ParseX25519Recipient(raw)
+ if err != nil {
+ return fmt.Errorf("invalid age recipient %q: %w", raw, err)
+ }
+ ageRecipients = append(ageRecipients, r)
+ }
+ if len(ageRecipients) == 0 {
+ return fmt.Errorf("AGE_RECIPIENTS is empty — at least one public key is required")
+ }
+ return nil
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Age encryption
+// ─────────────────────────────────────────────────────────────────────────────
+
+// ageEncrypt encrypts data for all ageRecipients and returns the ciphertext.
+func ageEncrypt(data []byte) ([]byte, error) {
+ var buf bytes.Buffer
+ w, err := age.Encrypt(&buf, ageRecipients...)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := w.Write(data); err != nil {
+ return nil, err
+ }
+ if err := w.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+// s3PutAge age-encrypts data then uploads it under key (appending ".age").
+func s3PutAge(ctx context.Context, key string, data []byte) error {
+ enc, err := ageEncrypt(data)
+ if err != nil {
+ return fmt.Errorf("age encrypt %s: %w", key, err)
+ }
+ return s3Put(ctx, key+".age", enc, "application/octet-stream")
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// S3 helpers
// ─────────────────────────────────────────────────────────────────────────────
@@ -161,6 +219,33 @@ func s3Exists(ctx context.Context, key string) bool {
return err == nil
}
+// s3DeletePrefix deletes all objects whose key starts with prefix.
+func s3DeletePrefix(ctx context.Context, prefix string) error {
+ paginator := s3.NewListObjectsV2Paginator(s3c, &s3.ListObjectsV2Input{
+ Bucket: aws.String(s3BucketName),
+ Prefix: aws.String(prefix),
+ })
+ deleted := 0
+ for paginator.HasMorePages() {
+ page, err := paginator.NextPage(ctx)
+ if err != nil {
+ return err
+ }
+ for _, obj := range page.Contents {
+ if _, err := s3c.DeleteObject(ctx, &s3.DeleteObjectInput{
+ Bucket: aws.String(s3BucketName),
+ Key: obj.Key,
+ }); err != nil {
+ slog.Warn("Failed to delete S3 object", "key", *obj.Key, "error", err)
+ } else {
+ deleted++
+ }
+ }
+ }
+ slog.Info("Deleted S3 prefix", "prefix", prefix, "count", deleted)
+ return nil
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Crypto-store tarball (persists sessions and sync tokens across CronJob runs)
// ─────────────────────────────────────────────────────────────────────────────
@@ -417,59 +502,63 @@ type exportedSessionEntry struct {
SessionKey string `json:"session_key"`
}
+// megolmSessions maps session IDs to live inbound sessions ready to decrypt.
+type megolmSessions map[id.SessionID]olm.InboundGroupSession
+
// fetchAndExportKeyBackup derives the Megolm backup private key from SSSS,
// decrypts every backed-up session, and uploads both a raw JSON archive and a
// standard .key export file (importable by any Matrix client) to S3.
-func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recoveryKeyStr, prefix string) error {
+// It also returns an in-memory session map for use when decrypting room history.
+func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recoveryKeyStr, prefix string) (megolmSessions, error) {
var defaultKey defaultKeyEventContent
if err := getAccountData(ctx, client, "m.secret_storage.default_key", &defaultKey); err != nil {
- return fmt.Errorf("get default key: %w", err)
+ return nil, fmt.Errorf("get default key: %w", err)
}
if defaultKey.Key == "" {
- return fmt.Errorf("m.secret_storage.default_key has no 'key' field")
+ return nil, fmt.Errorf("m.secret_storage.default_key has no 'key' field")
}
keyID := defaultKey.Key
slog.Info("SSSS default key ID", "key_id", keyID)
var keyMetadata ssss.KeyMetadata
if err := getAccountData(ctx, client, "m.secret_storage.key."+keyID, &keyMetadata); err != nil {
- return fmt.Errorf("get key metadata: %w", err)
+ return nil, fmt.Errorf("get key metadata: %w", err)
}
// ErrUnverifiableKey just means the metadata has no MAC to check against —
// the derived key is still usable.
sssKey, err := keyMetadata.VerifyRecoveryKey(keyID, recoveryKeyStr)
if err != nil && !errors.Is(err, ssss.ErrUnverifiableKey) {
- return fmt.Errorf("verify recovery key: %w", err)
+ return nil, fmt.Errorf("verify recovery key: %w", err)
}
slog.Info("Recovery key verified (or unverifiable but accepted)")
var backupSecret encryptedSecretContent
if err := getAccountData(ctx, client, "m.megolm_backup.v1", &backupSecret); err != nil {
- return fmt.Errorf("get backup secret: %w", err)
+ return nil, fmt.Errorf("get backup secret: %w", err)
}
encData, ok := backupSecret.Encrypted[keyID]
if !ok {
- return fmt.Errorf("backup secret not encrypted with key %q", keyID)
+ return nil, fmt.Errorf("backup secret not encrypted with key %q", keyID)
}
backupKeyRaw, err := sssKey.Decrypt("m.megolm_backup.v1", encData)
if err != nil {
- return fmt.Errorf("decrypt backup key from SSSS: %w", err)
+ return nil, fmt.Errorf("decrypt backup key from SSSS: %w", err)
}
privateKeyBytes, err := decodeBackupPrivateKey(backupKeyRaw)
if err != nil {
- return fmt.Errorf("decode backup private key: %w", err)
+ return nil, fmt.Errorf("decode backup private key: %w", err)
}
megolmKey, err := backup.MegolmBackupKeyFromBytes(privateKeyBytes)
if err != nil {
- return fmt.Errorf("create megolm backup key: %w", err)
+ return nil, fmt.Errorf("create megolm backup key: %w", err)
}
slog.Info("Decoded megolm backup private key from SSSS")
var backupVersion keyBackupVersionResp
if err := matrixGetJSON(ctx, client, "/_matrix/client/v3/room_keys/version", &backupVersion); err != nil {
- return fmt.Errorf("get backup version: %w", err)
+ return nil, fmt.Errorf("get backup version: %w", err)
}
slog.Info("Key backup version", "version", backupVersion.Version)
@@ -478,11 +567,13 @@ func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recove
"/_matrix/client/v3/room_keys/keys?version="+url.QueryEscape(backupVersion.Version),
&allRooms,
); err != nil {
- return fmt.Errorf("get backup keys: %w", err)
+ return nil, fmt.Errorf("get backup keys: %w", err)
}
slog.Info("Fetched key backup", "rooms", len(allRooms.Rooms))
- var sessions []exportedSessionEntry
+ // Decrypt each session: build both the export list and the live session map.
+ sessions := make(megolmSessions)
+ var exported []exportedSessionEntry
decOK, decFail := 0, 0
for roomID, room := range allRooms.Rooms {
for sessionID, sessionInfo := range room.Sessions {
@@ -500,7 +591,15 @@ func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recove
decFail++
continue
}
- sessions = append(sessions, exportedSessionEntry{
+
+ // Build the in-memory session for history decryption.
+ if sess, err := olm.InboundGroupSessionImport([]byte(sessionData.SessionKey)); err == nil {
+ sessions[id.SessionID(sessionID)] = sess
+ } else {
+ slog.Warn("Failed to import Megolm session", "session_id", sessionID, "error", err)
+ }
+
+ exported = append(exported, exportedSessionEntry{
Algorithm: string(sessionData.Algorithm),
ForwardingCurve25519KeyChain: sessionData.ForwardingKeyChain,
RoomID: string(roomID),
@@ -512,30 +611,30 @@ func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recove
decOK++
}
}
- slog.Info("Session decryption complete", "ok", decOK, "failed", decFail)
+ slog.Info("Session decryption complete", "ok", decOK, "failed", decFail, "in_memory", len(sessions))
- if len(sessions) == 0 {
+ if len(exported) == 0 {
slog.Warn("No sessions decrypted — skipping key export")
- return nil
+ return sessions, nil
}
- if rawJSON, err := json.MarshalIndent(sessions, "", " "); err == nil {
- _ = s3Put(ctx, prefix+"/backup-sessions-latest.json", rawJSON, "application/json")
- _ = s3Put(ctx, prefix+"/backup-sessions-"+dateStr+".json", rawJSON, "application/json")
+ if rawJSON, err := json.MarshalIndent(exported, "", " "); err == nil {
+ _ = s3PutAge(ctx, prefix+"/backup-sessions-latest.json", rawJSON)
+ _ = s3PutAge(ctx, prefix+"/backup-sessions-"+dateStr+".json", rawJSON)
}
- exportData, err := buildMegolmExport(sessions, keyExportPass)
+ exportData, err := buildMegolmExport(exported, keyExportPass)
if err != nil {
- return fmt.Errorf("build megolm export: %w", err)
+ return sessions, fmt.Errorf("build megolm export: %w", err)
}
- if err := s3Put(ctx, prefix+"/crypto-keys-latest.bin", exportData, "application/octet-stream"); err != nil {
- return err
+ if err := s3PutAge(ctx, prefix+"/crypto-keys-latest.bin", exportData); err != nil {
+ return sessions, err
}
- if err := s3Put(ctx, prefix+"/crypto-keys-"+dateStr+".bin", exportData, "application/octet-stream"); err != nil {
- return err
+ if err := s3PutAge(ctx, prefix+"/crypto-keys-"+dateStr+".bin", exportData); err != nil {
+ return sessions, err
}
- slog.Info("Exported E2EE keys", "sessions", len(sessions), "bytes", len(exportData))
- return nil
+ slog.Info("Exported E2EE keys", "sessions", len(exported), "bytes", len(exportData))
+ return sessions, nil
}
// decodeBackupPrivateKey extracts the raw 32-byte Curve25519 private key from
@@ -561,6 +660,43 @@ func decodeBackupPrivateKey(raw []byte) ([]byte, error) {
}
// ─────────────────────────────────────────────────────────────────────────────
+// Megolm event decryption
+// ─────────────────────────────────────────────────────────────────────────────
+
+// tryDecryptEvent attempts to decrypt an m.room.encrypted event using the
+// in-memory session map. Returns the original event unchanged if decryption
+// isn't possible (missing session, wrong algorithm, parse error).
+func tryDecryptEvent(ev *event.Event, sessions megolmSessions) *event.Event {
+ if ev.Type != event.EventEncrypted || sessions == nil {
+ return ev
+ }
+ content := ev.Content.AsEncrypted()
+ if content == nil || content.Algorithm != id.AlgorithmMegolmV1 {
+ return ev
+ }
+ sess, ok := sessions[content.SessionID]
+ if !ok {
+ return ev
+ }
+ plaintext, _, err := sess.Decrypt(content.MegolmCiphertext)
+ if err != nil {
+ slog.Debug("Megolm decrypt failed", "session_id", content.SessionID, "error", err)
+ return ev
+ }
+ var inner struct {
+ Type string `json:"type"`
+ Content json.RawMessage `json:"content"`
+ }
+ if err := json.Unmarshal(plaintext, &inner); err != nil {
+ return ev
+ }
+ decrypted := *ev
+ decrypted.Type = event.NewEventType(inner.Type)
+ decrypted.Content = event.Content{VeryRaw: inner.Content}
+ return &decrypted
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
// Megolm key export (spec: https://spec.matrix.org/v1.9/client-server-api/#key-exports)
// ─────────────────────────────────────────────────────────────────────────────
@@ -679,19 +815,18 @@ func saveRoomList(ctx context.Context, client *mautrix.Client, syncResp *mautrix
if name == "" {
name = string(roomID)
}
+ mc := 0
+ if joinedRoom.Summary.JoinedMemberCount != nil {
+ mc = *joinedRoom.Summary.JoinedMemberCount
+ }
rooms = append(rooms, roomEntry{
RoomID: string(roomID),
Name: name,
Type: rtype,
Aliases: aliases,
CanonicalAlias: canonicalAlias,
- MemberCount: func() int {
- if joinedRoom.Summary.JoinedMemberCount != nil {
- return *joinedRoom.Summary.JoinedMemberCount
- }
- return 0
- }(),
- Encrypted: encrypted,
+ MemberCount: mc,
+ Encrypted: encrypted,
})
slog.Info("Room", "type", rtype, "name", name)
}
@@ -700,10 +835,10 @@ func saveRoomList(ctx context.Context, client *mautrix.Client, syncResp *mautrix
if err != nil {
return err
}
- if err := s3Put(ctx, prefix+"/rooms-"+dateStr+".json", data, "application/json"); err != nil {
+ if err := s3PutAge(ctx, prefix+"/rooms-"+dateStr+".json", data); err != nil {
return err
}
- if err := s3Put(ctx, prefix+"/rooms-latest.json", data, "application/json"); err != nil {
+ if err := s3PutAge(ctx, prefix+"/rooms-latest.json", data); err != nil {
return err
}
slog.Info("Uploaded room list", "rooms", len(rooms))
@@ -765,7 +900,6 @@ func downloadAndStoreMedia(ctx context.Context, client *mautrix.Client, mxcURL,
return err
}
req.Header.Set("Authorization", "Bearer "+client.AccessToken)
-
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
@@ -838,8 +972,7 @@ var mediaMsgTypes = map[string]bool{
}
// processEvent downloads media and records profile changes for a single event.
-// Encrypted events are stored verbatim — without an OlmMachine we can't decrypt
-// them at backup time, so media extraction is skipped for those.
+// The event should already be decrypted before this is called.
func processEvent(ctx context.Context, client *mautrix.Client, ev *event.Event, roomID id.RoomID, prefix string, isDM bool) {
if ev.Type == event.EventEncrypted {
return
@@ -951,22 +1084,24 @@ type historyEvent struct {
Type string `json:"type"`
Timestamp int64 `json:"origin_server_ts"`
Content json.RawMessage `json:"content"`
+ Encrypted bool `json:"encrypted,omitempty"`
}
-func eventToRecord(ev *event.Event) historyEvent {
+func eventToRecord(ev *event.Event, wasEncrypted bool) historyEvent {
return historyEvent{
EventID: string(ev.ID),
Sender: string(ev.Sender),
Type: ev.Type.Type,
Timestamp: ev.Timestamp,
Content: ev.Content.VeryRaw,
+ Encrypted: wasEncrypted,
}
}
-// paginateRoom fetches room history and appends new events to the per-room
-// JSONL file in S3. For DMs on first run, the full history is fetched
-// backwards from the current position before normal forward pagination begins.
-func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID, prefix string, isDM bool, prevBatch string) error {
+// paginateRoom fetches room history and writes new events to a per-run
+// age-encrypted JSONL file in S3. For DMs on first run, the full history is
+// fetched backwards from the current position before normal forward pagination.
+func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID, prefix string, isDM bool, prevBatch string, sessions megolmSessions) error {
safeKey := strings.NewReplacer("/", "_", ":", "_").Replace(string(roomID))
cursorKey := prefix + "/history-cursor/" + safeKey + ".json"
@@ -995,7 +1130,9 @@ func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID,
break
}
for _, ev := range resp.Chunk {
- messages = append(messages, eventToRecord(ev))
+ wasEncrypted := ev.Type == event.EventEncrypted
+ ev = tryDecryptEvent(ev, sessions)
+ messages = append(messages, eventToRecord(ev, wasEncrypted))
processEvent(ctx, client, ev, roomID, prefix, true)
}
if resp.End == "" || resp.End == token {
@@ -1007,14 +1144,16 @@ func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID,
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
- histKey := prefix + "/history/" + safeKey + "/" + dateStr + ".jsonl"
+ histKey := prefix + "/history/" + safeKey + "/" + runStr + ".jsonl"
var buf bytes.Buffer
for _, m := range messages {
line, _ := json.Marshal(m)
buf.Write(line)
buf.WriteByte('\n')
}
- _ = s3Put(ctx, histKey, buf.Bytes(), "application/x-ndjson")
+ if err := s3PutAge(ctx, histKey, buf.Bytes()); err != nil {
+ slog.Warn("Failed to upload DM history", "room_id", roomID, "error", err)
+ }
slog.Info("DM history stored", "room_id", roomID, "events", len(messages))
}
}
@@ -1025,7 +1164,7 @@ func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID,
var messages []historyEvent
nextToken := cursor.Token
- histKey := prefix + "/history/" + safeKey + "/" + dateStr + ".jsonl"
+ histKey := prefix + "/history/" + safeKey + "/" + runStr + ".jsonl"
for range 100 {
resp, err := client.Messages(ctx, roomID, nextToken, "", mautrix.DirectionForward, nil, 100)
@@ -1037,7 +1176,9 @@ func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID,
break
}
for _, ev := range resp.Chunk {
- messages = append(messages, eventToRecord(ev))
+ wasEncrypted := ev.Type == event.EventEncrypted
+ ev = tryDecryptEvent(ev, sessions)
+ messages = append(messages, eventToRecord(ev, wasEncrypted))
processEvent(ctx, client, ev, roomID, prefix, isDM)
}
if resp.End == "" || resp.End == nextToken {
@@ -1048,15 +1189,15 @@ func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID,
}
if len(messages) > 0 {
- existing, _ := s3Get(ctx, histKey)
var buf bytes.Buffer
- buf.Write(existing)
for _, m := range messages {
line, _ := json.Marshal(m)
buf.Write(line)
buf.WriteByte('\n')
}
- _ = s3Put(ctx, histKey, buf.Bytes(), "application/x-ndjson")
+ if err := s3PutAge(ctx, histKey, buf.Bytes()); err != nil {
+ slog.Warn("Failed to upload history", "room_id", roomID, "error", err)
+ }
slog.Info("History updated", "room_id", roomID, "new_events", len(messages))
}
@@ -1109,8 +1250,11 @@ func backupAccount(ctx context.Context, acc accountCfg) error {
}
slog.Info("Sync done", "rooms", len(syncResp.Rooms.Join))
+ var sessions megolmSessions
if acc.SSSSKey != "" {
- if err := fetchAndExportKeyBackup(ctx, client, acc.SSSSKey, acc.Prefix); err != nil {
+ var err error
+ sessions, err = fetchAndExportKeyBackup(ctx, client, acc.SSSSKey, acc.Prefix)
+ if err != nil {
slog.Warn("Key backup export failed", "error", err)
}
}
@@ -1122,7 +1266,7 @@ func backupAccount(ctx context.Context, acc accountCfg) error {
dmRooms := getDMRooms(syncResp)
for roomID, joinedRoom := range syncResp.Rooms.Join {
isDM := dmRooms[roomID]
- if err := paginateRoom(ctx, client, roomID, acc.Prefix, isDM, joinedRoom.Timeline.PrevBatch); err != nil {
+ if err := paginateRoom(ctx, client, roomID, acc.Prefix, isDM, joinedRoom.Timeline.PrevBatch, sessions); err != nil {
slog.Warn("History error", "room_id", roomID, "error", err)
}
}
@@ -1146,6 +1290,11 @@ func main() {
ctx := context.Background()
+ if err := initAgeRecipients(mustEnv("AGE_RECIPIENTS")); err != nil {
+ slog.Error("Failed to init age recipients", "error", err)
+ os.Exit(1)
+ }
+
if err := initS3(ctx); err != nil {
slog.Error("Failed to init S3", "error", err)
os.Exit(1)
diff --git a/apps/talos_cluster/matrix-backup/secret.yaml b/apps/talos_cluster/matrix-backup/secret.yaml
@@ -5,13 +5,14 @@ metadata:
namespace: matrix-backup
type: Opaque
stringData:
- mtrnord_password: ENC[AES256_GCM,data:rLhj2nEU49Vi,iv:spylZf2eUmJAz1sxYYs3FH7ogyvL/Uh0IRn3gIxEOh4=,tag:TdiNa8DLfSUThCoKqCw4tA==,type:str]
- mtrnord_ssss_recovery_key: ENC[AES256_GCM,data:ETn7dgqZpDCaLk8JZN/8ALsoL59USF/QWhGpZcsyqDM6bkSjBNPPiXr7Sbs/5rnFQH4NY0MRlpdG4Yc=,iv:VUXCLS4mwMmmviI0ZrttaKFRpFGNDnF9dzCJ4RREfTc=,tag:yBmhc/aX5+U/Y4q5bmp3ng==,type:str]
- lexi_password: ENC[AES256_GCM,data:QVU6xUkfHcys,iv:poksZTGryhSU6S7M5K4uUjhxflgT3AwOiThgG9rf9nk=,tag:vnEr1NtWa3PSR+cz+7WFwQ==,type:str]
- lexi_ssss_recovery_key: ENC[AES256_GCM,data:/uK2cf5d65VrU4/rentCPHLMdTkvc5gVL1yOJmi4LUq/L4RReO2tgAq+ZBl9fPaJYVdsQRlSlOojWjU=,iv:Q7gurpu4xJmO8NNM7yXpHFYzu60IMYCugIRmJTXrmRI=,tag:K8+hXLX5yv4ZmRr0iOXxcg==,type:str]
- s3_access_key: ENC[AES256_GCM,data:idq3pSR7TekY9Pcr4DLnLIK0Awk=,iv:Y3awxY8Jau+niPqhdYST/ekm75g6nES2HY8NOghr1gM=,tag:ymp64EtXsHv5hxmJJ2QMcQ==,type:str]
- s3_secret_key: ENC[AES256_GCM,data:7U2jWXr87Rj6f3b80C5DZM9K1NSkIbzCXPN9ukecz2owhajeyuQ8hQ==,iv:r4XpfSjn2cAvOxbAcUbL8gB7+2CA7geioJhpdETcqac=,tag:b0qx8wJEQhFT41+JPiBm4w==,type:str]
- key_export_passphrase: ENC[AES256_GCM,data:yB1+XS1YoiAAL23Mu2a/V0sI/fGOGN9vaKyH9ePw/yvA66J1QoRBXKhxr4rJCTkYhfzaCn4IoTLrFPxIvvzZ,iv:RV9KjtobYjsz7jVSvCaD7SOJW5Wxt9na9SPjArT67xg=,tag:WcAkwSgCzc5Mdb96OrkBfA==,type:str]
+ mtrnord_password: ENC[AES256_GCM,data:wpvzy453lYFp,iv:H+55b0Q1mrpGwyzuI/e1uC4+zm/sF9Y3xrfitgeJK7Y=,tag:zAXmD7JG9LKe7EnD5WMUSw==,type:str]
+ mtrnord_ssss_recovery_key: ENC[AES256_GCM,data:7t4pVgEWg/K80Ff8IZOn/eYk9Ws/D9eP/0UKb5Zb0dFKLZ3tgKvrcy5WtUtKBQhQDTLXQKn2p2yGSEE=,iv:GhQnpk9KcDI+EYNWTN4HS+e/lPboBwTjmHoXmODXLss=,tag:QrGPGONB4Ri836HN4XJFAw==,type:str]
+ lexi_password: ENC[AES256_GCM,data:J/FBiAJ+E68D,iv:IPLWnSZdI7/gls9vpSVV0IU/xgPTvLyhM+9g5HXt0xQ=,tag:dCnS6nOtMGhHOmK/zA0XQw==,type:str]
+ lexi_ssss_recovery_key: ENC[AES256_GCM,data:ZZkWp4iN79kviyBAvb2T7hWz/NVyo1mtP4E3GE97D1+v9tzT1CDPzqvKWg+jnE5APANroHO42jW/XtE=,iv:gZRhjCAksN9F65Sora1bnj6Hb7czV2CXxuK1VvE4tSY=,tag:bSbFOzrgWnfO9Yfkxidg9w==,type:str]
+ s3_access_key: ENC[AES256_GCM,data:bFs7IJa4MGcOO1dW730ixz/WEV4=,iv:GMJGZF1UQxKIogHQJa41K/V9IB1LFMk+iJEaiSVHd0c=,tag:Ln23zYajbXoo/4bpL3alnA==,type:str]
+ s3_secret_key: ENC[AES256_GCM,data:PIN8mj9wGuMo7yE4wU3k7fTm3P3Rtt9fVFGydICSFgif9cvWV3yPWQ==,iv:AREyk7sUcG+eYxKgZSaV9PLmn9el9VRtErY49yUOChE=,tag:5Wc65aahq2O/m50dPPNj+A==,type:str]
+ key_export_passphrase: ENC[AES256_GCM,data:b36fFcraIOQFU8yk0Q701Fhe4chbLJTlF/t3WuVJc/l74PZmhAkIz3itTFbHRfBXWrZgD8+SQAAM9RGHBKsF,iv:LjsTK7YBP3qnnrJ+ydP2JjdEW6vClRZ/zzl5yeJbxQM=,tag:qIeP341xoZQWT7G3vWMnxg==,type:str]
+ age_recipients: ENC[AES256_GCM,data:379jjM7R7VT+CYo46VhiSP9hFfpNawC/DG23XY5cK69V3WRnolul6/RfpEkbzG/mCa4+IldRBfBcGiF/0mU=,iv:bMkatG+qnJcjG1RCXUxgs7wTj4/FG06TU2GmezLWMRc=,tag:zC6UVXLyPzkX4c9UC6DALA==,type:str]
sops:
kms: []
gcp_kms: []
@@ -21,14 +22,14 @@ sops:
- recipient: age1esjyg2qfy49awv0ptkzvpk425adczjr38m37w2mmcahzc4p8n54sll2nzh
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxUktNQmlTUk1RN2JicTla
- RjVFb0V5amtIczVuRVhWTjFIQXdEUVJ3TVdzClJadDNEYStwQW02L3VkK0NuNmxr
- TVRCdHRObXBBOUxPaHZ3ZnozbFIwVk0KLS0tIFZBVFRWOUtZaG9rTmZQbDBKVThr
- RmxKbkZkanZkSTRyeWM4Rm9jcWhUWkUKzmGRfGP9LQT1ypQYb70+Sfls29m3B/Va
- w2WG8BfC2L3Pt/eKalgcPdrqafZ0KWwn/YK6TFw2UDFaQhlq6CkAYw==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1VkJOK3lCV0p2UEZoLzI5
+ Z0gwYStvbHQvR0tPYTNTQkliUllTdWxBWTFFCjU1d0tjWEJpbWlaYnBibGVWS0JN
+ WlowWW96TkJRKzFtV1puUjg3TzVZYWsKLS0tIDRmWThjL0pqZ2ZaaEduLzhnMitE
+ TVMwYzZyWTRPWXJJcXdvSDlUc3V2RVEK+gBDUWRYYfB58PHx2wuU/wT1vhAnqvUD
+ kwK74QfYOtW5eYIYqqQpXgsmywSUZX0KWV/oJB0nWSH87jDAnor1GA==
-----END AGE ENCRYPTED FILE-----
- lastmodified: "2026-03-29T19:15:46Z"
- mac: ENC[AES256_GCM,data:B0jvpT7VB0V9rkm1HEUHOLV9EcrVYDaP2Wzwjxg90QvsB5xkhPem6Wd3+jqNcWHnBsKG9XvE0skrzbGdK0ESBMeRUVBrmV9jCxWDPNRhkcjuRqvaxbyrr2C79tsEqWMkJmlUZy+1DsWQ/pyDourKHyt2nw1dZwOJUDexLXfiOsQ=,iv:fvQ+s3OpUrDitaOzJepQT0DoeZh2G8GZ5iOAi56Rz5U=,tag:TngETHbq43LZGX9oHiz4kg==,type:str]
+ lastmodified: "2026-04-04T00:17:18Z"
+ mac: ENC[AES256_GCM,data:n9o7hQbPlW5hwbCIQi+eWonq9FcXeDNQpycajNq7JUfJr65mehiUsW7E7lnyjEVeYzIvC2zPLQn3Aue7GESxUUpPaojiH6+KFYSHDuViAzr7crldORqqyi7E1CzT90gLYySy++YbiDIyk2XJ3wsCa8wREu/9x1i1BXcrKZeZidM=,iv:5CX6QY6gRk5YPNm1XaI5IRG6LimMSdf+0d2Y/l0wfOo=,tag:7xFCwKMMQTbbqu/xnT+I9g==,type:str]
pgp: []
encrypted_regex: ^(apiKey|appUserPassword|otelUserPassword|harborAdminPassword|totpVaultKey|kimaiAppSecret|kimaiAdminPassword|GITHUB_CLIENT_ID|GITHUB_CLIENT_SECRET|GITHUB_PRIVATE_KEY|woosh|root_password|rspamd_password|pgdb_password|matrix_access_token|pgdb_remote_url|hmac_secret_key|adminPassword|adminEmail|jenkinsAdminEmail|securityRealm|gerrit.config|routing_key|DATABASE_URL|SMTP_PASSWORD|SECRET_KEY_BASE|admin_password|extraCommands|key|clickhouseDatabaseURL|databaseURL|client_id|client_secret|secret_key_base|otp_secret|private_key|public_key|primaryKey|deterministicKey|keyDerivationSalt|token|clientId|secretKey|installationId|installationKey|uriOverride|adminToken.value|password.value|sql_password|erlangCookie|AUTHENTICATION_PASSWORD|ROOM_API_SECRET_KEY|adminPassword|configPassword|adminUser|configUser|MAIL_PASSWORD|APP_KEY|api_key|api_secret|keys|livekit_key|livekit_secret|secret_key|admin_pass|admin_email|mariadbPassword|mariadbRootPassword|privateKey|data|stringData|PASSWD|password|pass|postgresPassword|smtp_auth_password|addresses|smtp_auth_username|authorization_credentials|postgresqlPassword|redminePassword|smtpPassword|registration_shared_secret|shared_secret|secret|admin_token|integrationKey|integration_key|rootPassword|adminPassword|adminUser|adminEmail|emailPassword|secretKey|appId|clientSecret|webhookSecret)$
version: 3.9.1