diff --git a/internal/dotsync/dotsync_test.go b/internal/dotsync/dotsync_test.go index 73ea66b..fa70794 100644 --- a/internal/dotsync/dotsync_test.go +++ b/internal/dotsync/dotsync_test.go @@ -2,6 +2,7 @@ package dotsync import ( "bytes" + "encoding/base64" "encoding/json" "fmt" "io" @@ -763,10 +764,21 @@ func formatKind(data []byte) string { case f.values == nil: return "age" // entirely ciphertext default: - return "sops" // encrypted values + return "values" // encrypted values in a readable file } } +// ageFile is a minimal age file: version line, one recipient stanza, header MAC, payload. +var ageFile = "age-encryption.org/v1\n-> X25519 abc\nZGVm\n--- bWFj\n\x00\xff\x10\x80" + +// zstdRaw wraps data in a zstd frame with a single raw block, as zstd stores incompressible data. +func zstdRaw(data string) string { + n := len(data)<<3 | 1 // last block, type raw + return "\x28\xb5\x2f\xfd\x00\x58" + string([]byte{byte(n), byte(n >> 8), byte(n >> 16)}) + data +} + +func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + func TestEncryptedFiles(t *testing.T) { w := newWorld(t) w.machine("alpha").activate() @@ -777,23 +789,36 @@ func TestEncryptedFiles(t *testing.T) { enc string blocked bool }{ - {".config/mise/.env.yaml", sopsYAML, "sops", false}, - {".config/mise/.env.json", sopsJSON, "sops", false}, - {"app/.env.json", `{"k":"ENC[AES256_GCM,data:eA==,iv:A,tag:B,type:str]","sops":{"mac":"ENC[AES256_GCM,data:bQ==,iv:A,tag:B,type:str]"}}`, "sops", false}, - {".env", "API_KEY=ENC[AES256_GCM,data:eA==,iv:A,tag:B,type:str]\nsops_mac=ENC[AES256_GCM,data:bQ==,iv:A,tag:B,type:str]\n", "sops", false}, + {".config/mise/.env.yaml", sopsYAML, "values", false}, + {".config/mise/.env.json", sopsJSON, "values", false}, + {"app/.env.json", `{"k":"ENC[AES256_GCM,data:eA==,iv:A,tag:B,type:str]","sops":{"mac":"ENC[AES256_GCM,data:bQ==,iv:A,tag:B,type:str]"}}`, "values", false}, + {".env", "API_KEY=ENC[AES256_GCM,data:eA==,iv:A,tag:B,type:str]\nsops_mac=ENC[AES256_GCM,data:bQ==,iv:A,tag:B,type:str]\n", "values", false}, {"secrets/prod.env.age", ageArmored, "age", false}, {"secrets/prod.env.age", "age-encryption.org/v1\n-> X25519 abc\nZGVm\n--- bWFj\n\x00\xff", "age", false}, // The age header alone isn't enough: plaintext after it is scanned (and the path refused). {"secrets/prod.env.age", "age-encryption.org/v1\npassword = hunter2!xyz\n", "", true}, // In sops files only the ENC[…] values are skipped: a plaintext secret on the same line // (e.g. a minified JSON file) is still found. - {".config/mise/.env.json", `{"k":"ENC[AES256_GCM,data:eA==,iv:A,tag:B,type:str]","token":"ghp_` + strings.Repeat("a", 36) + `","sops":{"mac":"ENC[AES256_GCM,data:bQ==,iv:A,tag:B,type:str]"}}`, "sops", true}, + {".config/mise/.env.json", `{"k":"ENC[AES256_GCM,data:eA==,iv:A,tag:B,type:str]","token":"ghp_` + strings.Repeat("a", 36) + `","sops":{"mac":"ENC[AES256_GCM,data:bQ==,iv:A,tag:B,type:str]"}}`, "values", true}, // sops leaves keys and comments readable; those are still scanned. - {".config/mise/.env.yaml", "# token: " + "ghp_" + strings.Repeat("a", 36) + "\n" + sopsYAML, "sops", true}, + {".config/mise/.env.yaml", "# token: " + "ghp_" + strings.Repeat("a", 36) + "\n" + sopsYAML, "values", true}, // ENC[…] lines are only skipped in real sops files. {"notes.txt", "ENC[AES256_GCM,data:x] token: ghp_" + strings.Repeat("a", 36) + "\n", "", true}, // Plaintext between age armor lines isn't ciphertext. {"secrets/prod.env.age", "-----BEGIN AGE ENCRYPTED FILE-----\npassword = hunter2!xyz\n-----END AGE ENCRYPTED FILE-----\n", "", true}, + // age values embedded in a readable file, plain or zstd-compressed (mise's two formats), and + // in other formats: the path rules don't apply, and neither do the content rules to the value. + {".config/mise/conf.d/secrets.toml", "[env]\nGITHUB_TOKEN = { age = \"" + b64(ageFile) + "\" }\n", "values", false}, + {".config/mise/conf.d/secrets.toml", "[env]\nDB_PASSWORD = { age = { value = \"" + b64(zstdRaw(ageFile)) + "\", format = \"zstd\" } }\n", "values", false}, + {".config/app/.env", "API_TOKEN=" + strings.TrimRight(b64(ageFile), "=") + "\n", "values", false}, + {".config/app/config.yaml", "api:\n token: '" + b64(ageFile) + "'\n", "values", false}, + // Everything else in such a file is still scanned, on other lines and on the same line. + {".config/mise/config.toml", "[env]\nA = { age = \"" + b64(ageFile) + "\" }\nB = \"ghp_" + strings.Repeat("a", 36) + "\"\n", "values", true}, + {".config/app/config.json", `{"a": {"age": "` + b64(ageFile) + `"}, "password": "hunter2!xyz"}`, "values", true}, + // base64 that isn't age ciphertext is plaintext as far as the rules are concerned. + {".config/app/config.yaml", "token: '" + b64("hunter2hunter2hunter2hunter2hunter2") + "'\n", "", true}, + {".config/app/config.yaml", "token: '" + b64(zstdRaw("hunter2hunter2hunter2hunter2hunter2")) + "'\n", "", true}, + {".config/app/config.yaml", "token: '" + b64("age-encryption.org/v1\npassword = hunter2!xyz\n") + "'\n", "", true}, // Unencrypted files named like secrets are still refused. {".config/mise/.env.json", `{"API_KEY": "abc"}`, "", true}, } { @@ -813,6 +838,8 @@ func TestEncryptedSecretsSyncInManagedDirectory(t *testing.T) { a.init() a.write(".config/mise/config.toml", "[env]\n_.file = \".env.yaml\"\n") a.write(".config/mise/.env.yaml", sopsYAML) + ageValues := "[env]\nDB_PASSWORD = { age = \"" + b64(ageFile) + "\" }\n" + a.write(".config/mise/conf.d/secrets.toml", ageValues) a.write(".config/mise/age.txt", fakeAgeKey+"\n") out := a.ok("add", a.path(".config/mise")) if !strings.Contains(out, "1 file(s) under ~/.config/mise look like secrets") || !strings.Contains(out, "age.txt") { @@ -821,9 +848,13 @@ func TestEncryptedSecretsSyncInManagedDirectory(t *testing.T) { if w.remoteFile("mise/.env.yaml") != sopsYAML { t.Fatal("sops file was not sent") } + if w.remoteFile("mise/conf.d/secrets.toml") != ageValues { + t.Fatal("file with age values was not sent") + } b.init() b.expect(".config/mise/.env.yaml", sopsYAML) + b.expect(".config/mise/conf.d/secrets.toml", ageValues) if b.exists(".config/mise/age.txt") { t.Fatal("age key reached another machine") } diff --git a/internal/dotsync/secrets.go b/internal/dotsync/secrets.go index 4afe2ad..742ed5a 100644 --- a/internal/dotsync/secrets.go +++ b/internal/dotsync/secrets.go @@ -2,6 +2,7 @@ package dotsync import ( "bytes" + "encoding/base64" "fmt" "path/filepath" "regexp" @@ -71,7 +72,7 @@ func secretContentReason(data []byte) string { continue } if f != nil && f.values != nil { - line = f.values.ReplaceAll(line, nil) + line = f.values(line) } for _, r := range secretContent { if r.re.Match(line) { @@ -105,16 +106,14 @@ func secretReason(path string, o *Obj) string { // cipherFormat recognises an encrypted file format. type cipherFormat struct { detect func(data []byte) bool - values *regexp.Regexp // encrypted values in an otherwise readable file; nil when the whole file is ciphertext + values func(line []byte) []byte // removes the encrypted values from a line of an otherwise readable file; nil when the whole file is ciphertext } // cipherFormats are the encrypted formats dotsync recognises. They're data: the code above -// never refers to a format by name. +// never refers to a format by name, and none of them names the tool that writes it. var cipherFormats = []cipherFormat{ // age, binary: the version line, recipient stanzas, then the header MAC line ("--- …"). - {detect: func(d []byte) bool { - return bytes.HasPrefix(d, []byte("age-encryption.org/v1\n")) && bytes.Contains(d[:min(len(d), 64<<10)], []byte("\n--- ")) - }}, + {detect: isAgeFile}, // age, ASCII armor: nothing but base64 between the armor lines. {detect: func(d []byte) bool { return armored(d, []byte("-----BEGIN AGE ENCRYPTED FILE-----"), []byte("-----END AGE ENCRYPTED FILE-----")) @@ -123,10 +122,70 @@ var cipherFormats = []cipherFormat{ // encrypted individually. { detect: regexp.MustCompile(`(?m)(?:"mac"\s*:\s*"|^\s*mac:\s*|^\s*mac\s*=\s*"?|^sops_mac=)ENC\[AES256_GCM,`).Match, - values: regexp.MustCompile(`ENC\[AES256_GCM,[^\]]*\]`), + values: removeAll(regexp.MustCompile(`ENC\[AES256_GCM,[^\]]*\]`)), + }, + // age values in a readable file (TOML, YAML, JSON, dotenv…): base64 of an age file, optionally + // zstd-compressed, as mise's age-encrypted environment variables are stored. + { + detect: func(d []byte) bool { + for _, m := range base64Run.FindAll(d, -1) { + if isAgeValue(m) { + return true + } + } + return false + }, + values: func(line []byte) []byte { + return base64Run.ReplaceAllFunc(line, func(m []byte) []byte { + if isAgeValue(m) { + return nil + } + return m + }) + }, }, } +func removeAll(re *regexp.Regexp) func([]byte) []byte { + return func(line []byte) []byte { return re.ReplaceAll(line, nil) } +} + +var ageHeader = []byte("age-encryption.org/v1\n") + +// isAgeFile reports whether d is age ciphertext: the version line, then a header ending in its +// MAC line. +func isAgeFile(d []byte) bool { + return bytes.HasPrefix(d, ageHeader) && bytes.Contains(d[:min(len(d), 64<<10)], []byte("\n--- ")) +} + +// base64Run matches a whole run of base64 long enough to hold an age header; a leftmost greedy +// match always starts and ends at the run's boundaries. +var base64Run = regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`) + +var zstdMagic = []byte{0x28, 0xb5, 0x2f, 0xfd} + +// isAgeValue reports whether b64 decodes to an age file, or to a zstd frame holding one. +// Ciphertext doesn't compress, so zstd stores it in a raw block: the age file then starts right +// after the frame and block headers (at most 21 bytes), unchanged. +func isAgeValue(b64 []byte) bool { + enc := base64.StdEncoding + if !bytes.HasSuffix(b64, []byte("=")) && len(b64)%4 != 0 { + enc = base64.RawStdEncoding + } + d := make([]byte, enc.DecodedLen(len(b64))) + n, err := enc.Decode(d, b64) + if err != nil { + return false + } + d = d[:n] + if bytes.HasPrefix(d, zstdMagic) { + if i := bytes.Index(d[:min(len(d), 32)], ageHeader); i > 0 { + d = d[i:] + } + } + return isAgeFile(d) +} + func cipherFormatOf(data []byte) *cipherFormat { for i := range cipherFormats { if cipherFormats[i].detect(data) { diff --git a/website/src/content/docs/concepts/security-model.mdx b/website/src/content/docs/concepts/security-model.mdx index 4147368..32a8067 100644 --- a/website/src/content/docs/concepts/security-model.mdx +++ b/website/src/content/docs/concepts/security-model.mdx @@ -31,7 +31,7 @@ description: What dotsync trusts, what it defends against and what it doesn't, w Encryption means getting a key onto every machine. That key becomes a new secret to bootstrap, rotate and lose, and a single point of compromise. Keeping secrets **out** of the repository is simpler, and fails safe. Use a password manager or the OS keychain for secrets, and let dotsync handle configuration. -dotsync doesn't encrypt anything itself, but it doesn't stand in the way if **you** do. Files already encrypted with [age](https://age-encryption.org) or [sops](https://getsops.io), for example by mise, are ciphertext, so they sync like any other file. Getting the key to each machine is then up to you, and dotsync keeps its side of it: it refuses to sync age private keys, by path and by content. It recognizes encrypted **file formats**, not the tools that write them, so this works whichever tool you use. See [encrypted files](/dotsync/reference/secret-rules/#encrypted-files). +dotsync doesn't encrypt anything itself, but it doesn't stand in the way if **you** do. Files encrypted with [age](https://age-encryption.org) or [sops](https://getsops.io), and age-encrypted values such as mise writes into its configuration, are ciphertext, so they sync like any other file. Getting the key to each machine is then up to you, and dotsync keeps its side of it: it refuses to sync age private keys, by path and by content. It recognizes encrypted **formats**, not the tools that write them, so this works whichever tool you use. See [Encrypt secrets that should sync](/dotsync/guides/secrets/#encrypt-secrets-that-should-sync). ## What dotsync sends, and to whom diff --git a/website/src/content/docs/guides/mise.mdx b/website/src/content/docs/guides/mise.mdx index 3c25491..ddbe022 100644 --- a/website/src/content/docs/guides/mise.mdx +++ b/website/src/content/docs/guides/mise.mdx @@ -98,18 +98,19 @@ Create `~/.config/mise/local.env` on each machine. The `--ignore '*.env'` above ### Encrypt them with age -mise can store an age-encrypted value directly in the config (an experimental feature at the time of writing): +This is the simplest way to sync secrets, if you already use mise. mise can store an age-encrypted value directly in the config. It's an experimental feature at the time of writing, so turn those on first: ```sh -mise set --age-encrypt --prompt DB_PASSWORD +mise settings experimental=true +mise set -g --age-encrypt --prompt DB_PASSWORD ``` ```toml title="~/.config/mise/config.toml" [env] -DB_PASSWORD = { age = { value = "" } } +DB_PASSWORD = { age = "" } ``` -Only ciphertext reaches the repository, so the file syncs without any exceptions. +Long values are compressed and stored as `{ age = { value = "", format = "zstd" } }`. dotsync recognizes both forms as ciphertext, so the file syncs without any exceptions. That's true even for a file named like a secret, such as `conf.d/secrets.toml`. The rest of the file is still checked. [Encrypt secrets that should sync](/dotsync/guides/secrets/#encrypt-secrets-that-should-sync) covers the same approach with other tools. ### Encrypted files with sops @@ -124,18 +125,21 @@ dotsync recognizes sops files and syncs them even though the name looks like a s ### The age key -Both approaches depend on an age private key, by default `~/.config/mise/age.txt`. mise can also use your SSH key. Anyone with that key and a copy of the repository can read every secret, so it has to stay off the repository. dotsync refuses it by path (`.config/mise/age.txt`, `.config/sops/age/*`) and by content (`AGE-SECRET-KEY-1…`). +Both approaches depend on an age private key, by default `~/.config/mise/age.txt`. Create it once with `age-keygen -o ~/.config/mise/age.txt` (install age with `mise use -g age`). Anyone with that key and a copy of the repository can read every secret, so it has to stay off the repository. dotsync refuses it by path (`.config/mise/age.txt`, `.config/sops/age/*`) and by content (`AGE-SECRET-KEY-1…`). -Copy it to each new machine yourself, once. dotsync never syncs it, and it doesn't check for it either: until the key is there, the encrypted values simply show up as missing in `mise env`. +Use **one key on all your machines**. mise can also decrypt with each machine's SSH key, but then every secret has to be re-encrypted for each new machine. Keep a copy of the key in your password manager, and copy it to each new machine yourself, once: -1. On a machine that has the key: `cat ~/.config/mise/age.txt`, or store it in your password manager. -2. On the new machine: paste it into `~/.config/mise/age.txt` and `chmod 600 ~/.config/mise/age.txt`. -3. Check it works: `mise env` should now list `DB_PASSWORD`. +1. On the new machine, paste the key from your password manager into `~/.config/mise/age.txt`, then `chmod 600 ~/.config/mise/age.txt`. +2. Check it works: `mise env` should now list `DB_PASSWORD`. +Until the key is there, mise reports an error each time it fails to decrypt a value. If you'd rather it skipped those values quietly, for example on a machine that should never have them, run `mise settings age.strict=false`. + +If the key leaks, rotate every secret it protected: old ciphertext stays in the repository's history. + ## mise's own dotfiles mise can also manage dotfiles itself, with `mise dot` and a `[dotfiles]` section in its config. It links, copies or templates files from a source directory, and it can *track* files where they are, keeping their history in git. With the history watcher and `history.sync = "sync"`, it syncs tracked files between machines too. See [mise's dotfiles docs](https://mise.jdx.dev/dotfiles.html), and the [comparison](/dotsync/concepts/comparison/) for how it differs from dotsync. diff --git a/website/src/content/docs/guides/secrets.mdx b/website/src/content/docs/guides/secrets.mdx index 750be13..b64a0d6 100644 --- a/website/src/content/docs/guides/secrets.mdx +++ b/website/src/content/docs/guides/secrets.mdx @@ -1,6 +1,6 @@ --- title: Keep secrets out -description: How dotsync stops credentials from reaching your repository, what you see when it does, and how to structure dotfiles so secrets stay on each machine. +description: How dotsync stops plaintext credentials from reaching your repository, what you see when it does, and how to keep secrets local or sync them encrypted with the tool you already use. --- import { Aside, Tabs, TabItem } from '@astrojs/starlight/components'; @@ -30,7 +30,12 @@ BLOCKED files look like they contain secrets, so their changes stay on this mach https://pungoyal.github.io/dotsync/guides/secrets/ `; -dotsync's rule is simple: **secrets never leave the machine.** They aren't encrypted into the repository; they're kept out of it. Before any file is uploaded, dotsync checks its **path** and every line of its **content** against [a set of rules](/dotsync/reference/secret-rules/) for private keys, cloud credentials and tokens. +dotsync's rule is simple: **plaintext secrets never leave the machine.** Before any file is uploaded, dotsync checks its **path** and every line of its **content** against [a set of rules](/dotsync/reference/secret-rules/) for private keys, cloud credentials and tokens. + +That leaves you two ways to handle a secret: + +- **Keep it out** of synced files, and have them load it from a local file or a password manager. See [below](#move-secrets-out-of-synced-files). +- **Encrypt it** with a tool you already use, such as mise, sops or age, so that only ciphertext reaches the repository. See [Encrypt secrets that should sync](#encrypt-secrets-that-should-sync). ## What you'll see @@ -90,9 +95,76 @@ export GITHUB_TOKEN=$(op read op://dev/github/token) # fine: read at runtime export GITHUB_TOKEN=ghp_… # blocked ``` -## Encrypted files +## Encrypt secrets that should sync + +If you'd rather have a secret reach every machine than set it up on each one, encrypt it. dotsync doesn't encrypt anything itself, and doesn't need to know which tool you use. It recognizes [encrypted formats](/dotsync/reference/secret-rules/#encrypted-files), not tools: age files, sops files, and age-encrypted values inside an otherwise readable file. These sync even when their name looks like a secret, and only ciphertext reaches the repository. + +dotsync syncs a file exactly as it is on disk, so whatever reads the file has to decrypt it. The simplest way to do that is to decrypt secrets into **environment variables** and have your other dotfiles refer to the variables: + + + +mise decrypts [age-encrypted values](https://mise.jdx.dev/environments/secrets/age.html) in its configuration whenever it loads the environment. Enable its experimental features, create a key, then encrypt each secret into your global configuration: + +```sh +mise settings experimental=true +age-keygen -o ~/.config/mise/age.txt # once: the key (install age with `mise use -g age`) +mise set -g --age-encrypt --prompt GITHUB_TOKEN +``` + +```toml title="~/.config/mise/config.toml (synced)" +[env] +GITHUB_TOKEN = { age = "YWdlLWVuY3J5cHRpb24ub3JnL3Yx…" } +``` -Files encrypted with [age](https://age-encryption.org) or [sops](https://getsops.io) sync like any other file, even when their name looks like a secret, as with `.env.json` or `secrets.yaml`. Only ciphertext reaches the repository. The age **private key** that decrypts them is always refused, so copy it to each new machine yourself. [Encrypted files](/dotsync/reference/secret-rules/#encrypted-files) lists exactly what is checked, and [Use with mise](/dotsync/guides/mise/#secrets) shows a complete setup. +[Use with mise](/dotsync/guides/mise/#secrets) has the rest of the setup. + + +[sops](https://getsops.io) encrypts the values in a dotenv, YAML or JSON file and leaves the keys readable. Load the file with mise (`_.file`), direnv, or `sops exec-env`: + +```sh +age-keygen -o ~/.config/sops/age/keys.txt # once: the key, where sops looks for it on Linux +sops encrypt --age "$(age-keygen -y ~/.config/sops/age/keys.txt)" secrets.env > ~/.config/secrets.enc.env +``` + +```sh title="~/.zshrc (synced)" +set -a; . <(sops decrypt ~/.config/secrets.enc.env); set +a +``` + +On macOS, sops looks for the key in `~/Library/Application Support/sops/age/keys.txt` instead. + + +Plain [age](https://age-encryption.org) encrypts a whole file, and your shell decrypts it when it starts: + +```sh +age-keygen -o ~/.config/age/key.txt # once: the key +age -e -a -r "$(age-keygen -y ~/.config/age/key.txt)" -o ~/.config/secrets.sh.age secrets.sh +``` + +```sh title="~/.zshrc (synced)" +eval "$(age -d -i ~/.config/age/key.txt ~/.config/secrets.sh.age)" +``` + + + +Then refer to the variables, rather than the secrets, in your other dotfiles: + +```ini title="~/.npmrc (synced)" +//registry.npmjs.org/:_authToken=${NPM_TOKEN} +``` + +Many tools read their credentials straight from the environment, for example `GH_TOKEN` for the GitHub CLI and `AWS_ACCESS_KEY_ID` for the AWS CLI. + +### One key, copied by hand + +Everything encrypted is only as safe as the private key that decrypts it, so: + +- **Use one key for yourself, not one per machine.** A key per machine means re-encrypting every secret whenever you add a machine. +- **Keep a copy in your password manager**, and paste it onto each new machine once: create the file, then `chmod 600` it. +- **dotsync never syncs it.** It refuses age private keys by their content (`AGE-SECRET-KEY-1…`) wherever they are, and by the default paths of mise and sops. Until a machine has the key, it can't decrypt anything, and the tool that tries tells you so. + + ## False positives diff --git a/website/src/content/docs/reference/secret-rules.mdx b/website/src/content/docs/reference/secret-rules.mdx index a8b26d2..2293d2c 100644 --- a/website/src/content/docs/reference/secret-rules.mdx +++ b/website/src/content/docs/reference/secret-rules.mdx @@ -51,9 +51,10 @@ DATABASE_URL=postgres://app:hunter2@db/app dotsync recognizes encrypted **file formats**, whichever tool wrote them: - **age files**, ASCII-armored (`-----BEGIN AGE ENCRYPTED FILE-----`) or binary (`age-encryption.org/v1`), are entirely ciphertext. No rule applies to them, whatever they're called. +- **age values** inside an otherwise readable file are recognized by decoding them: a run of base64 that decodes to an age file, or to a zstd-compressed one, is ciphertext. This is how mise stores [age-encrypted environment variables](/dotsync/guides/mise/#encrypt-them-with-age), in TOML, but any format works: YAML, JSON, dotenv. As with sops, the **path** rules don't apply to such a file, those values are ignored, and the **content** rules check everything else. - **sops files** are recognized by the encrypted `mac` that sops writes into every format it supports: JSON, YAML, dotenv and INI. The **path** rules don't apply to them, so a `.env.json` or `secrets.yaml` can sync. Their encrypted values (`ENC[AES256_GCM,…]`) are ignored, but the **content** rules still check every other part of every line, because sops leaves keys, comments and `_unencrypted` values readable. -The age **private key** that decrypts these files is always refused, by its path and by its content (`AGE-SECRET-KEY-1…`). Copy it to each machine yourself. +The age **private key** that decrypts them is always refused, by its path and by its content (`AGE-SECRET-KEY-1…`). Copy it to each machine yourself. ## Exceptions