@cryptotaxi247 / infra / commits / f3743a8b

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