ATHANOR
RU GitHub ↗

2 / 8

Encryption: age and sops

Set agents and MCP aside for a moment — everything on top rests on a simple question at the bottom: how do you store a secret on disk so the file can be copied, committed, even lost on a flash drive, and none of it matters without one specific thing?

age: a key pair instead of a password

age encrypts data to a key pair, not a password:

  • the public key (age1...) can only encrypt. Handing it out is fine;
  • the private key (AGE-SECRET-KEY-1...) can only decrypt. It's the one thing worth protecting.

A useful analogy: the public key is a mailbox slot anyone can drop a letter into; the private key is the key to the door — only it gets the letters back out.

Athanor sets this up as follows: athanor init generates a pair once (internal/crypto/identity.go, age.GenerateX25519Identity()) and stores the private half at ~/.config/athanor/identity.txt — one per developer by default, not one per project, so a single key backup covers every project at once. The public half (recipient) goes into athanor.yaml, where it's meant to be seen and safe to commit.

sops: encrypting values, not the whole file

age on its own turns data into one solid blob — you can't tell what's inside, and you can't diff it in git. sops fixes that: it encrypts values in a YAML/JSON document while leaving key names readable.

DB_PASSWORD: ENC[AES256_GCM,data:xqmOcd0=,iv:...,tag:...,type:str]
STRIPE_TEST_KEY: ENC[AES256_GCM,data:...,type:str]
sops:
    age:
        - recipient: age1...
          enc: |
              -----BEGIN AGE ENCRYPTED FILE-----
              ...

You can see which secrets exist without seeing what they're worth. That's exactly what athanor list gives you — names, no values.

How Athanor actually calls sops: no plaintext on disk

The important engineering decision here isn't what gets used — age and sops — it's how they're called. The usual interactive mode, sops file.yaml, writes a temporary decrypted file to disk, opens it in an editor, then re-encrypts it. That's exactly what Athanor avoids: a temporary plaintext file is a leak waiting to happen.

Instead, internal/crypto/sops.go routes everything through stdin/stdout:

READ (Decrypt):
  sops --decrypt --input-type yaml --output-type json secrets.enc.yaml
       (SOPS_AGE_KEY_FILE=identity.txt in the sops process's environment)
                    │
                    ▼
         JSON arrives on the sops process's stdout
                    │
                    ▼
     Athanor parses it straight into memory (map[string]string)
     — nothing touches disk

WRITE (Encrypt):
  Athanor serializes the map to JSON, in memory
                    │
                    ▼ (passed via stdin, not a file)
  sops --encrypt --input-type json --output-type yaml
       --age <recipient> /dev/stdin
                    │
                    ▼
     the encrypted YAML arrives on the sops process's stdout
                    │
                    ▼
     Athanor writes it to secrets.enc.yaml with one os.WriteFile (chmod 600)

If secrets.enc.yaml doesn't exist yet, Decrypt just returns an empty map — a normal "new store," not an error.

Who owns what

  • age is only responsible for the key pair existing and for sops knowing how to use it — Athanor never calls age directly to encrypt a file, only the filippo.io/age Go library, to generate an identity during athanor init.
  • sops is an external binary, called via os/exec, and it does all the real cryptographic work. Athanor doesn't hold or reinvent any of it — "don't write your own crypto" is worth following everywhere, not just here.
  • Athanor decides when to call sops, what to feed it, and who gets the result — that's the access model, and it's the subject of the next chapter.

Checkpoint question. Why doesn't interactive sops file.yaml mode fit Athanor, and what replaces it?

Answer: interactive mode writes its own temporary decrypted file to disk for the editor, while Athanor guarantees plaintext never touches disk at all. It uses non-interactive sops --decrypt/--encrypt instead, with data moving through stdin/stdout and living only in Athanor's own process memory.