Skip to content

Encryption and keys

'ENCRYPTION': {
    'BACKEND': 'offsite_backup.encryption.age_cli.AgeCli',
    'RECIPIENTS': os.environ.get('BACKUP_AGE_RECIPIENT', ''),
},

Every object is encrypted with age to one or more public keys. The server holds only public keys, so it can write a backup and cannot read one. The private half lives off the machine — in your password manager, and nowhere else.

Making a key pair

On your own machine, not the server:

age-keygen -o backup-acme-prod.key
# Public key: age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq
  • The public key (age1…) goes into the server's environment, e.g. BACKUP_AGE_RECIPIENT. It is not a secret.
  • The file holds the private key (AGE-SECRET-KEY-1…). Put it in your password manager, then delete the file. age-keygen -y backup-acme-prod.key prints the public key again from it.

One key pair per product and environment keeps a leaked key's reach small. Losing the private key loses every backup sealed to it — store it where at least two people can reach it.

RECIPIENTS

One public key, a list, or one string holding several. A string is split on commas and newlines, and between age1… keys on whitespace too — an ssh-ed25519 … recipient keeps the space inside it. Blank items are dropped, so the environment idiom works as it is:

'RECIPIENTS': os.environ.get('BACKUP_AGE_RECIPIENTS', ''),   # 'age1abc..., age1def...'

On a machine where the variable is unset this is '' — and a list of blanks, [os.environ.get('X', '')], counts as unset too: with the store credentials present, E001 names RECIPIENTS (Settings).

With several recipients, any one of the private keys opens a backup — a second key for a colleague, or a break-glass key in a safe.

age's SSH recipients (ssh-ed25519 …, ssh-rsa …) are accepted too.

manage.py check refuses anything else in RECIPIENTS as E008 — above all a private key, which would put the means to read every backup on the machine they protect:

?: (offsite_backup.E008) OFFSITE_BACKUP['ENCRYPTION']['RECIPIENTS'] holds a PRIVATE age key.
    HINT: Put the public half (age1...) here; the private half belongs off this machine.

Compress, then encrypt

The order is not a preference: an encrypted stream does not compress. Databases and the configuration archive go source | zstd | age; media volumes go tar | age, because images and video are compressed already.

'COMPRESS': ['zstd', '-19', '--long=27', '-T0', '-q'],   # the default
'DECOMPRESS': ['zstd', '-d', '-q', '--long=31'],         # what a restore runs

-19 --long=27 is slow and small, which suits a batch job at night. A large database that does not finish in its window is better served by -10 than by no compression; an empty COMPRESS turns the stage off. DECOMPRESS must accept the window COMPRESS used — --long=31 accepts any. A restore decides whether to decompress from zstd's magic bytes, not from the name.

Every stage runs under pipefail semantics: a stage that dies mid-stream fails the pass instead of leaving a small, valid, truncated file.

A second key for one dump: the wake identity

Some databases must be restorable by the server itself — a tenant put to sleep and woken on demand. Seal that one dump to an extra recipient whose private key the server holds, and keep every other backup readable off the machine only:

from offsite_backup import back_up_database, restore_database

key = back_up_database('tenant_42', extra_recipients=[settings.WAKE_RECIPIENT])
...
restore_database(key, 'tenant_42', identity=settings.WAKE_IDENTITY_FILE)
# or, with the key's text rather than a file:
restore_database(key, 'tenant_42', identity_text=settings.WAKE_IDENTITY)

identity_text is written to a private 0600 temporary file and removed afterwards. It is for this dedicated, narrowly used identity only — never the backups' main key.

The server's wake key opens those dumps and nothing else. Treat it as a secret of the server, with the same care as its database password.

Without the binary: pyrage

'ENCRYPTION': {
    'BACKEND': 'offsite_backup.encryption.pyrage.Pyrage',
    'RECIPIENTS': os.environ.get('BACKUP_AGE_RECIPIENTS', ''),
},

pip install 'django-offsite-backup[pyrage]' — pyrage is age's Rust implementation in a wheel, for an image where installing the age binary is the hard part. It is the same format: a backup sealed by Pyrage opens with age -d, and one sealed by age opens with Pyrage — CI seals with each and opens with the other. So switching between the two needs no re-encryption, and a restore on your own machine can use whichever is installed there.

RECIPIENTS, E008 and the extra recipients work exactly as for AgeCli; a recipient pyrage itself cannot read is E008 as well. Identity files are age's: AGE-SECRET-KEY-1… lines (comments allowed), or one SSH private key.

A pipeline stage is a process, so each sealing stage is this interpreter running python -m offsite_backup.encryption.pyrage seal age1… (or open <identity file> on restore), streaming stdin to stdout through pyrage.encrypt_io / decrypt_io — memory stays flat whatever the size, and the stage fails the pass like any other under pipefail. Recipients are public keys and the identity is a file path, so argv holds no secret. Where sys.executable is not Python — inside uWSGI — set 'PYTHON': '/usr/local/bin/python'.

Not yet

An explicit "no encryption" backend and helpers to re-seal recent backups to a new recipient are on the roadmap. For tests, offsite_backup.testing.Passthrough stands in for age — and raises W002 so it is never mistaken for encryption (Testing).