Add support for mailing lists with login accounts
With this change, mailing lists can now have associated login accounts, which allow folks to send emails via SMTP from a `@nixos.org` email address. This completes https://github.com/NixOS/infra/issues/510 I opted to keep dovecot around. We're not using it for IMAP, just for `SASL` authentication. It would have requires some (brittle) overriding of settings from `nixos-mailserver` to get rid of dovecot. If we really want to get rid of dovecot someday, I believe we should try to add an option upstream in `simple-nixos-mailserver`.
Jeremy Fleischman committed
Nov 9, 2024 at 18:48 UTC
f3743a8ba2f38610715914c5e427da92f72ae231
10 files changed
+360
-176
non-critical-infra/flake-module.nix
+1
-1
@@ -56,7 +56,7 @@
56
pkgs = inputs'.nixpkgs-unstable.legacyPackages;
57
in
58
{
59
- packages.encrypt-email-address = pkgs.callPackage ./packages/encrypt-email-address { };
59
+ packages.encrypt-email = pkgs.callPackage ./packages/encrypt-email { };
60
61
devShells.non-critical-infra = pkgs.mkShellNoCC {
62
packages = [
non-critical-infra/modules/mailserver/README.md
+3
-6
@@ -5,10 +5,7 @@ This module will [eventually][issue 485] provide mail services for `nixos.org`.
5
## Mailing lists
6
7
To create a new mailing list, or change membership of a mailing list, see the
8
-instructions at the top of [`mailing-lists.nix`](./mailing-lists.nix).
8
+instructions under `### Mailing lists go here ###` in [`default.nix`](./default.nix).
9
10
-## Sending mail
11
-
12
-This module does not yet provide SMTP login.
13
-
14
-[issue 485]: https://github.com/NixOS/infra/issues/485
10
+Some mailing lists allow login and sending email via `SMTP`. Search for
11
+`loginAccount` to find examples of this.
non-critical-infra/modules/mailserver/default.nix
+22
-4
@@ -7,13 +7,31 @@
7
enable = true;
8
certificateScheme = "acme-nginx";
9
10
- # Until we have login accounts, there's no reason to run either of these.
11
- enablePop3 = false;
12
- enableImap = false;
13
-
10
fqdn = config.networking.fqdn;
11
12
# TODO: change to `nixos.org` when ready
13
domains = [ "mail-test.nixos.org" ];
14
};
15
+
16
+ ### Mailing lists go here ###
17
+ # If you wish to hide your email address, you can encrypt it with SOPS. Just
18
+ # run `nix run .#encrypt-email address -- --help` and follow the instructions.
19
+ #
20
+ # If you wish to set up a login account for sending email, you must generate
21
+ # an encrypted password. Run `nix run .#encrypt-email login -- --help` and
22
+ # follow the instructions.
23
+ mailing-lists = {
24
+ # TODO: replace with the real `nixos.org` mailing lists.
25
+ "test-list@mail-test.nixos.org" = {
26
+ forwardTo = [
27
+ "jfly@playground.jflei.com"
28
+ ../../secrets/jfly-email-address.umbriel
29
+ "jeremyfleischman+subscriber@gmail.com"
30
+ ];
31
+ };
32
+ "test-sender@mail-test.nixos.org" = {
33
+ forwardTo = [ "jeremy@playground.jflei.com" ];
34
+ loginAccount.encryptedHashedPassword = ../../secrets/test-sender-email-login.umbriel;
35
+ };
36
+ };
37
}
non-critical-infra/modules/mailserver/mailing-lists.nix
+109
-44
@@ -1,69 +1,134 @@
1
-# This module provides the mailing list definitions for `@nixos.org`.
1
+# This module makes it easy to define mailing lists in `simple-nixos-mailserver`
2
+# with a couple of features:
3
#
3
-# Simply change the `lists` attribute set below to create new mailing lists or
4
-# edit membership of existing lists.
5
-#
6
-# If you wish to hide your email address, you can encrypt it with SOPS. Just
7
-# run `nix run .#encrypt-email-address -- --help` and follow the instructions.
4
+# 1. We can (optionally) encrypt the forward addresses for increase privacy.
5
+# 2. We can set up a login account for mailing addresses to allow sending
6
+# email via `SMTP` from those addresses.
7
8
{ config, lib, ... }:
9
10
let
12
- # Mailing lists go here.
13
- # TODO: replace with the real `nixos.org` mailing lists.
14
- listsWithSecretFiles = {
15
- "test-list@mail-test.nixos.org" = [
16
- "jfly@playground.jflei.com"
17
- ../../secrets/jfly-email.umbriel
18
- "jeremyfleischman+subscriber@gmail.com"
19
- ];
20
- };
11
+ inherit (lib) types;
12
13
fileToSecretId = file: builtins.baseNameOf file;
14
24
- listsWithSecretPlaceholders = lib.mapAttrs' (name: members: {
15
+ listsWithSecretPlaceholders = lib.mapAttrs' (name: mailingList: {
16
name = name;
17
value = map (
18
member:
19
if builtins.isString member then member else config.sops.placeholder.${fileToSecretId member}
29
- ) members;
30
- }) listsWithSecretFiles;
20
+ ) mailingList.forwardTo;
21
+ }) config.mailing-lists;
22
32
- secretFiles = lib.pipe listsWithSecretFiles [
33
- (lib.mapAttrsToList (_name: members: members))
23
+ secretAddressFiles = lib.pipe config.mailing-lists [
24
+ (lib.mapAttrsToList (_name: mailingList: mailingList.forwardTo))
25
lib.flatten
26
(builtins.filter (member: !builtins.isString member))
27
];
28
+
29
+ secretPasswordFiles = lib.pipe config.mailing-lists [
30
+ (lib.filterAttrs (_name: mailingList: mailingList.loginAccount != null))
31
+ (lib.mapAttrsToList (_name: mailingList: mailingList.loginAccount.encryptedHashedPassword))
32
+ ];
33
in
34
35
{
40
- # Declare secrets for every secret email in the lists above.
41
- sops.secrets = builtins.listToAttrs (
42
- map (file: {
43
- name = fileToSecretId file;
44
- value = {
45
- format = "binary";
46
- sopsFile = file;
47
- };
48
- }) secretFiles
49
- );
36
+ options = {
37
+ mailing-lists = lib.mkOption {
38
+ type = types.attrsOf (
39
+ types.submodule {
40
+ options = {
41
+ forwardTo = lib.mkOption {
42
+ type = types.listOf (types.either types.str types.path);
43
+ description = ''
44
+ Either a plaintext email address, or a path to an email address
45
+ encrypted with `nix run .#encrypt-email address`
46
+ '';
47
+ };
48
+ loginAccount = lib.mkOption {
49
+ type = types.nullOr (
50
+ types.submodule {
51
+ options = {
52
+ encryptedHashedPassword = lib.mkOption {
53
+ type = types.path;
54
+ description = ''
55
+ If specified, this enables sending emails from this address via SMTP.
56
+ Must be a path to encrypted file generated with `nix run .#encrypt-email login`
57
+ '';
58
+ };
59
+ };
60
+ }
61
+ );
62
+ default = null;
63
+ };
64
+ };
65
+ }
66
+ );
67
+ description = ''
68
+ Mailing lists. Supports both forward-only mailing lists, as well as mailing
69
+ lists that allow sending via SMTP.
70
+ '';
71
+ };
72
+ };
73
51
- sops.templates."postfix-virtual-mailing-lists" = {
52
- content = lib.concatStringsSep "\n" (
53
- lib.mapAttrsToList (
54
- name: members: "${name} ${lib.concatStringsSep ", " members}"
55
- ) listsWithSecretPlaceholders
74
+ config = {
75
+ # Disable IMAP. We don't need it, as we don't store email on this server, we
76
+ # only forward emails.
77
+ mailserver.enableImap = false;
78
+ mailserver.enableImapSsl = false;
79
+ services.dovecot2.enableImap = false;
80
+
81
+ mailserver.loginAccounts = lib.pipe config.mailing-lists [
82
+ (lib.filterAttrs (_name: mailingList: mailingList.loginAccount != null))
83
+ (lib.mapAttrs (
84
+ _name: mailingList: {
85
+ hashedPasswordFile =
86
+ config.sops.secrets.${fileToSecretId mailingList.loginAccount.encryptedHashedPassword}.path;
87
+ }
88
+ ))
89
+ ];
90
+
91
+ # Declare secrets for every secret file.
92
+ sops.secrets = builtins.listToAttrs (
93
+ (map (file: {
94
+ name = fileToSecretId file;
95
+ value = {
96
+ format = "binary";
97
+ sopsFile = file;
98
+ };
99
+ }) secretAddressFiles)
100
+ ++ (map (file: {
101
+ name = fileToSecretId file;
102
+ value = {
103
+ format = "binary";
104
+ sopsFile = file;
105
+ # Need to restart `dovecot2.service` to trigger `genPasswdScript` in
106
+ # `nixos-mailserver`:
107
+ # https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/af7d3bf5daeba3fc28089b015c0dd43f06b176f2/mail-server/dovecot.nix#L369
108
+ # This could go away if sops-nix gets support for "input addressed secret
109
+ # paths": https://github.com/Mic92/sops-nix/issues/648
110
+ restartUnits = [ "dovecot2.service" ];
111
+ };
112
+ }) secretPasswordFiles)
113
);
114
58
- # Need to restart postfix-setup to rerun `postmap` and generate updated `.db`
59
- # files whenever mailing list membership changes.
60
- # This could go away if sops-nix gets support for "input addressed secret
61
- # paths": https://github.com/Mic92/sops-nix/issues/648
62
- restartUnits = [ "postfix-setup.service" ];
63
- };
115
+ sops.templates."postfix-virtual-mailing-lists" = {
116
+ content = lib.concatStringsSep "\n" (
117
+ lib.mapAttrsToList (
118
+ name: members: "${name} ${lib.concatStringsSep ", " members}"
119
+ ) listsWithSecretPlaceholders
120
+ );
121
+
122
+ # Need to restart postfix-setup to rerun `postmap` and generate updated `.db`
123
+ # files whenever mailing list membership changes.
124
+ # This could go away if sops-nix gets support for "input addressed secret
125
+ # paths": https://github.com/Mic92/sops-nix/issues/648
126
+ restartUnits = [ "postfix-setup.service" ];
127
+ };
128
65
- services.postfix.mapFiles.virtual-mailing-lists =
66
- config.sops.templates."postfix-virtual-mailing-lists".path;
129
+ services.postfix.mapFiles.virtual-mailing-lists =
130
+ config.sops.templates."postfix-virtual-mailing-lists".path;
131
68
- services.postfix.config.virtual_alias_maps = [ "hash:/etc/postfix/virtual-mailing-lists" ];
132
+ services.postfix.config.virtual_alias_maps = [ "hash:/etc/postfix/virtual-mailing-lists" ];
133
+ };
134
}
non-critical-infra/packages/encrypt-email-address/default.nix
deleted
-20
@@ -1,20 +0,0 @@
1
-{
2
- lib,
3
- python3,
4
- sops,
5
-}:
6
-
7
-python3.pkgs.buildPythonApplication {
8
- name = "encrypt-email-address";
9
- src = ./.;
10
-
11
- format = "other";
12
-
13
- propagatedBuildInputs = [ python3.pkgs.click ];
14
-
15
- installPhase = ''
16
- mkdir -p $out/bin
17
- mv ./encrypt-email-address.py $out/bin/encrypt-email-address
18
- wrapProgram $out/bin/encrypt-email-address --prefix PATH : ${lib.makeBinPath [ sops ]}
19
- '';
20
-}
non-critical-infra/packages/encrypt-email-address/encrypt-email-address.py
deleted
-101
@@ -1,101 +0,0 @@
1
-#!/usr/bin/env python3
2
-
3
-import re
4
-import subprocess
5
-from pathlib import Path
6
-
7
-import click
8
-
9
-
10
-def find_project_root(start: Path) -> Path:
11
- # Can search for `flake.nix` because there are multiple in this project.
12
- root_indicator = start / ".git/config"
13
- if root_indicator.exists():
14
- return start
15
-
16
- return find_project_root(start.parent)
17
-
18
-
19
-@click.command()
20
-@click.argument("address_id")
21
-@click.argument("email")
22
-@click.option("--force/--no-force", "-f/ ", default=False)
23
-def main(address_id: str, email: str, force: bool) -> None:
24
- """
25
- Encrypt an email address (or email addresses) for inclusion in a mailing list.
26
-
27
- Example:
28
-
29
- \bencrypt-email-address some-token 'me@example.com,you@example.com'
30
-
31
- Then follow the instructions for what to do next.
32
- """
33
- # Feel free to make the regex less restrictive if you need to.
34
- id_re = re.compile("[A-Za-z0-9-]+")
35
- if not id_re.fullmatch(address_id):
36
- msg = f"Given ID: {address_id!r} is invalid. Must match regex: {id_re.pattern}"
37
- raise click.ClickException(msg)
38
-
39
- # Make sure we aren't being given a text file that happens to have a newline at the end.
40
- clean_email = email.strip()
41
- if clean_email != email:
42
- click.secho("Removed whitespace surrounding given email address", fg="yellow")
43
- email = clean_email
44
-
45
- project_root = find_project_root(Path.cwd()).relative_to(Path.cwd(), walk_up=True)
46
- non_critical_infra_dir = project_root / "non-critical-infra"
47
-
48
- secret_path = non_critical_infra_dir / f"secrets/{address_id}-email.umbriel"
49
-
50
- if secret_path.exists():
51
- if not force:
52
- msg = f"Refusing to clobber existing {secret_path}. Use `--force` to override."
53
- raise click.ClickException(msg)
54
- click.secho(f"Clobbering existing {secret_path}", fg="yellow")
55
-
56
- sops_config = non_critical_infra_dir / ".sops.yaml"
57
- cp = subprocess.run(
58
- [
59
- "sops",
60
- "--encrypt",
61
- "--config",
62
- sops_config,
63
- "--filename-override",
64
- secret_path,
65
- "/dev/stdin",
66
- ],
67
- text=True,
68
- check=True,
69
- stdout=subprocess.PIPE,
70
- input=email,
71
- )
72
-
73
- secret_path.write_text(cp.stdout)
74
- subprocess.run(
75
- ["git", "add", "--intent-to-add", "--force", "--", secret_path], check=True
76
- )
77
-
78
- click.secho(f"Successfully generated {secret_path}", fg="green")
79
-
80
- mailing_list_nix = non_critical_infra_dir / "modules/mailserver/mailing-lists.nix"
81
- assert mailing_list_nix.exists()
82
-
83
- click.secho()
84
- click.secho("Now add yourself to ", nl=False)
85
- click.secho(mailing_list_nix, fg="blue", nl=False)
86
- click.secho(". ")
87
-
88
- click.secho()
89
- click.secho("Lastly, add `", nl=False)
90
- click.secho(
91
- secret_path.relative_to(mailing_list_nix.parent, walk_up=True),
92
- fg="blue",
93
- nl=False,
94
- )
95
- click.secho("` to the relevant mailing list under '", nl=False)
96
- click.secho("# Mailing lists go here.", fg="blue", nl=False)
97
- click.secho("'.")
98
-
99
-
100
-if __name__ == "__main__":
101
- main()
non-critical-infra/packages/encrypt-email/default.nix
new
+26
@@ -0,0 +1,26 @@
1
+{
2
+ lib,
3
+ mkpasswd,
4
+ python3,
5
+ sops,
6
+}:
7
+
8
+python3.pkgs.buildPythonApplication {
9
+ name = "encrypt-email";
10
+ src = ./.;
11
+
12
+ format = "other";
13
+
14
+ propagatedBuildInputs = [ python3.pkgs.click ];
15
+
16
+ installPhase = ''
17
+ mkdir -p $out/bin
18
+ mv ./encrypt-email.py $out/bin/encrypt-email
19
+ wrapProgram $out/bin/encrypt-email --prefix PATH : ${
20
+ lib.makeBinPath [
21
+ sops
22
+ mkpasswd
23
+ ]
24
+ }
25
+ '';
26
+}
non-critical-infra/packages/encrypt-email/encrypt-email.py
new
+171
@@ -0,0 +1,171 @@
1
+#!/usr/bin/env python3
2
+
3
+import re
4
+import subprocess
5
+import sys
6
+from pathlib import Path
7
+from textwrap import dedent, indent
8
+
9
+import click
10
+
11
+
12
+def find_project_root(start: Path) -> Path:
13
+ # Can search for `flake.nix` because there are multiple in this project.
14
+ root_indicator = start / ".git/config"
15
+ if root_indicator.exists():
16
+ return start
17
+
18
+ return find_project_root(start.parent)
19
+
20
+
21
+def find_relative_project_root() -> Path:
22
+ return find_project_root(Path.cwd()).relative_to(Path.cwd(), walk_up=True)
23
+
24
+
25
+def encrypt_to_file(plaintext: str, secret_path: Path, force: bool) -> None:
26
+ if secret_path.exists():
27
+ if not force:
28
+ msg = f"Refusing to clobber existing {secret_path}. Use `--force` to override."
29
+ raise click.ClickException(msg)
30
+ click.secho(f"Clobbering existing {secret_path}", fg="yellow")
31
+
32
+ cp = subprocess.run(
33
+ [
34
+ "sops",
35
+ "--encrypt",
36
+ "--filename-override",
37
+ secret_path,
38
+ "/dev/stdin",
39
+ ],
40
+ cwd=secret_path.parent,
41
+ text=True,
42
+ check=True,
43
+ stdout=subprocess.PIPE,
44
+ input=plaintext,
45
+ )
46
+
47
+ secret_path.write_text(cp.stdout)
48
+ subprocess.run(
49
+ ["git", "add", "--intent-to-add", "--force", "--", secret_path], check=True
50
+ )
51
+
52
+ click.secho(f"Successfully generated {secret_path}", fg="green")
53
+
54
+
55
+def hash_password(plaintext: str) -> str:
56
+ cp = subprocess.run(
57
+ ["mkpasswd", "--stdin", "--method=bcrypt"],
58
+ stdout=subprocess.PIPE,
59
+ input=plaintext,
60
+ text=True,
61
+ check=True,
62
+ )
63
+ return cp.stdout
64
+
65
+
66
+@click.group()
67
+def main() -> None:
68
+ pass
69
+
70
+
71
+@main.command()
72
+@click.argument("address_id")
73
+@click.argument("email")
74
+@click.option("--force/--no-force", "-f/ ", default=False)
75
+def address(address_id: str, email: str, force: bool) -> None:
76
+ """
77
+ Encrypt an email address (or email addresses) for inclusion in a mailing list.
78
+
79
+ Example:
80
+
81
+ \bencrypt-email address some-token 'me@example.com,you@example.com'
82
+
83
+ Then follow the instructions for what to do next.
84
+ """
85
+ # Feel free to make the regex less restrictive if you need to.
86
+ id_re = re.compile("[A-Za-z0-9-]+")
87
+ if not id_re.fullmatch(address_id):
88
+ msg = f"Given ID: {address_id!r} is invalid. Must match regex: {id_re.pattern}"
89
+ raise click.ClickException(msg)
90
+
91
+ # Make sure we aren't being given a text file that happens to have a newline at the end.
92
+ clean_email = email.strip()
93
+ if clean_email != email:
94
+ click.secho("Removed whitespace surrounding given email address", fg="yellow")
95
+ email = clean_email
96
+
97
+ project_root = find_relative_project_root()
98
+ non_critical_infra_dir = project_root / "non-critical-infra"
99
+
100
+ secret_path = non_critical_infra_dir / f"secrets/{address_id}-email-address.umbriel"
101
+ encrypt_to_file(email, secret_path, force)
102
+
103
+ default_nix = non_critical_infra_dir / "modules/mailserver/default.nix"
104
+ assert default_nix.exists()
105
+
106
+ click.secho()
107
+ click.secho("Now add `", nl=False)
108
+ click.secho(
109
+ secret_path.relative_to(default_nix.parent, walk_up=True),
110
+ fg="blue",
111
+ nl=False,
112
+ )
113
+ click.secho("` to the relevant mailing list under '", nl=False)
114
+ click.secho("### Mailing lists go here ###", fg="blue", nl=False)
115
+ click.secho("' in ", nl=False)
116
+ click.secho(default_nix, fg="blue")
117
+
118
+
119
+@main.command()
120
+@click.argument("address_id")
121
+@click.option("--force/--no-force", "-f/ ", default=False)
122
+def login(address_id: str, force: bool) -> None:
123
+ """
124
+ Encrypt a password to set up a login account for a mailing list. The password must be given via stdin.
125
+
126
+ Example:
127
+
128
+ \bencrypt-email login test-sender < file-with-password
129
+
130
+ Then follow the instructions for what to do next.
131
+ """
132
+ # Make sure we aren't being given a text file that happens to have a newline at the end.
133
+ password = sys.stdin.read()
134
+ clean_password = password.strip()
135
+ if clean_password != password:
136
+ click.secho("Removed whitespace surrounding given password", fg="yellow")
137
+ password = clean_password
138
+
139
+ hashed_password = hash_password(password)
140
+
141
+ project_root = find_relative_project_root()
142
+ non_critical_infra_dir = project_root / "non-critical-infra"
143
+
144
+ secret_path = non_critical_infra_dir / f"secrets/{address_id}-email-login.umbriel"
145
+ encrypt_to_file(hashed_password, secret_path, force)
146
+
147
+ default_nix = non_critical_infra_dir / "modules/mailserver/default.nix"
148
+ assert default_nix.exists()
149
+
150
+ nix_code = dedent(
151
+ f"""\
152
+ "{address_id}@mail-test.nixos.org" = {{
153
+ forwardTo = [
154
+ # Add emails here
155
+ ];
156
+ loginAccount.encryptedHashedPassword = ../../secrets/test-sender-email-login.umbriel;
157
+ }};
158
+ """
159
+ )
160
+ click.secho()
161
+ click.secho("Now add this login account to ", nl=False)
162
+ click.secho(default_nix, fg="blue", nl=False)
163
+ click.secho(". Search for '", nl=False)
164
+ click.secho("### Mailing lists go here ###", fg="blue", nl=False)
165
+ click.secho("'. Add or edit an entry that looks like this:")
166
+ click.secho()
167
+ click.secho(indent(nix_code, prefix=" " * 4), fg="blue")
168
+
169
+
170
+if __name__ == "__main__":
171
+ main()
non-critical-infra/secrets/jfly-email-address.umbriel
renamed
non-critical-infra/secrets/test-sender-email-login.umbriel
new
+28
@@ -0,0 +1,28 @@
1
+{
2
+ "data": "ENC[AES256_GCM,data:QrqhPVcJL1Q0VttVaPQ0fNuHdYyDXnSRy2EgWm/P1YRjBGLaSviwOCscYCyQw8Q8CqtPyFt2p4ddhpqueQ==,iv:ydnF/JhFy5mNDHdm/GJeS2PoRpQvAgRfFoumhCLNKsg=,tag:3zSqTVXmf2cl4G97lcF8og==,type:str]",
3
+ "sops": {
4
+ "kms": null,
5
+ "gcp_kms": null,
6
+ "azure_kv": null,
7
+ "hc_vault": null,
8
+ "age": [
9
+ {
10
+ "recipient": "age15vcp7875xwtf64j4yshyld0a3hpgzv6n2kxky493s3q0swr9hdaqxugpv6",
11
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTaTlvMm5iR0RlMHFDMEQw\nU0ZhRUVwOVlXWVhWSnJmNUN6d1lsL1lOUjNBCjNoWFI3L2R2dnFvMm96QzU2cENJ\nY2RoQVcwQkRYOVk1UFNqZTdTV2pCS0kKLS0tIC9EZFZISWFJQWdTSnpzQ2xFYkxq\nS1cveHJuOVE4ZmVsUUEzRGRKYWtYZncKccTmgBe1sdnpMYnTOV4gAUEBg93Blg18\n2gfJl1NUszoOGVnUq0HIVi0PHCFb4imMNhbF6INv0eQG5OPB0ElOig==\n-----END AGE ENCRYPTED FILE-----\n"
12
+ },
13
+ {
14
+ "recipient": "age1j3mkgedmeru63vwww6m44zfw09tg8yw6xdzstaq7ejfkvgcau40qwakm8x",
15
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5U3RDTnlBWk04RVFLV2xZ\ndU5qWmJiT3lPMW1UK3I1TDljWCs2dldDcFdrCmRDRDM5ZnlrTm1NS05YQU1PUTJS\nNjlWYitnWjgxMzI2YituMzJmK2w0VUkKLS0tIHhPc3lYR1c2TWkzS3NFcms5OGQ1\nMHpZVmFmZ0owYmR1OFB5LzJqZGx5UUUKB2j2Pa25K4rJ0PX961R3KBA3UZyOXPJw\nBtuyuKUo7Ro9oOVaIiezU1Z6ii8CY/WVrEpTRHkHbYSTOAZcLKY/qw==\n-----END AGE ENCRYPTED FILE-----\n"
16
+ },
17
+ {
18
+ "recipient": "age1jrh8yyq3swjru09s75s4mspu0mphh7h6z54z946raa9wx3pcdegq0x8t4h",
19
+ "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvK2REWTQ0eU9OMHZ2aWZM\nUTVQSFJOMHJrT2dwWSsyd2lsWVprMFFWdlY0ClZHc1dFYmJmY1dVcUJ0R2pKU0F0\nRlViQlZDMnpVQWMwZlg5aFEzS055U2sKLS0tIDhYenRPZkpVOWcrREYvUllrQnI5\nQ0U1a3R5R0dhTHhvYWRLbU5GaXh4UmsKJ23a/61odibLmp7UnbmiSkEwTErMlur2\nP1AZgvI1YZGaRo0211s5ffcV2fvmEuY3HxvIHIhby9HRC4B8wFIVUA==\n-----END AGE ENCRYPTED FILE-----\n"
20
+ }
21
+ ],
22
+ "lastmodified": "2024-11-10T00:44:29Z",
23
+ "mac": "ENC[AES256_GCM,data:VNo6aIkTOxKJFq5xxIo0IJV5bzY+Za9IZP0xuGqSJNj+/TwIV5VTVjMGmQiLB+hPX8ixXcpqAflO9KlWAwxId63dtnPNlGUtOp9ys03zV+QSv0ejmAQrGg4t4FkHxIowT8YLXAw0an0jQU1AdTU4kyoL8jY6Vz2y76FDqw+YRyc=,iv:XEILC5jtuegGkMm3dyMeaZ3RBixB9MWst3ncTENvMDI=,tag:8+FDT7M0MVie+dBGLB0S8A==,type:str]",
24
+ "pgp": null,
25
+ "unencrypted_suffix": "_unencrypted",
26
+ "version": "3.9.1"
27
+ }
28
+}
\ No newline at end of file