Skip to main content

simple_encrypt

A small file encryptor built on Daniel J. Bernstein's ChaCha20 and Salsa20.

The premise: if you need to read a file again in fifteen years, depending on an application is a bad bet. Applications patch defaults, deprecate modes, and change what a flag means. Depending on an algorithm is a much better bet — these ciphers are public domain, published in full, widely reimplemented, and small enough that a competent programmer can rebuild one from the specification in an afternoon.

So everything here is arranged around one question: when you come back to this in 2040, can you get your data out?

Read this first

This is an archival experiment and a worked argument, not general-purpose file encryption. It is the code I use for my own long-term archives, published because the underlying idea seemed worth writing down.

Two things you need to know before using it for anything you care about:

  • Nothing here is authenticated. Ciphertext carries no MAC. A bit flipped by decaying media decrypts to a flipped bit in your plaintext with no warning, and anyone who can modify the file can flip chosen bits in the plaintext without knowing your key. Decrypting with the wrong key produces garbage rather than an error. This is a deliberate scope decision — see Limitations — but it is the opposite of what a modern encryption tool should do by default, and it should not be something you discover at the bottom of a long page.
  • There is no passphrase and no key derivation. The key file is raw hex on disk and is exactly as sensitive as the data it protects.

If you want general-purpose file encryption, use age

Genuinely. It is authenticated, supports passphrases and public keys, is carefully built, and is packaged nearly everywhere. For almost everyone, almost always, it is the better choice, and I would rather point you there than have you use this by accident.

What age does not do is serve the specific premise above. age is an application — a well-made one, but the whole argument here is about not depending on an application. If age is unmaintained in 2045 and its format has drifted, you are back where this project started. The bet made here is different: keep the format so small and so self-describing that you could reimplement the decryptor from a published spec in an afternoon, and accept a worse tool in exchange for that.

Worth noting that age reaches for ChaCha20-Poly1305 too. The algorithm is the part everyone agrees on; the disagreement is only about how much machinery to wrap around it.

Status

Honest state of things:

  • Observed passing on two very different machines. macOS / arm64 / Apple clang, and Debian / s390x / gcc 14 under emulation. Those differ in processor architecture, operating system, compiler, and — the point of the exercise — byte order.
  • Big-endian works. This used to be an argument rather than a result: the ciphers are endian-independent by construction, since every integer/byte conversion goes through explicit byte-wise shifts and the sources contain no pointer casts to integer types, no unions, and no memcpy into integers. It has now been run on a genuinely big-endian machine (byteorder: big) and the full suite passes, minus only the OpenSSL cross-check, which skips when no openssl binary is present. Archives are portable across architectures.
  • CI is configured but has not run on a remote yet — there isn't one. .github/workflows/ci.yml covers Linux and macOS, gcc and clang, Python 3.8 / 3.11 / 3.13 with no compiler present at all, and repeats the s390x run. It is scheduled monthly rather than on every push: commits here are rare, make test covers most of it locally, and the value of a schedule is catching drift in runner images and toolchains rather than catching your own edits. After changing any C, trigger it by hand from the Actions tab — the cross-compiler and big-endian checks are the ones you cannot run at home.
  • A monthly job watches upstream. .github/workflows/upstream.yml re-fetches the NaCl tarball and RFC 8439 and fails if either has disappeared or changed contents. The trust root of this project lives on two servers it does not control, and the wrong time to discover one of them is gone is the day you need to recover something.
  • The C tool is POSIX-only (unistd.h, ssize_t, /dev/urandom). On Windows, use the pure-Python path, which has no such dependencies.
  • Nobody but the author has reviewed the code. Review is welcome and is part of why this is public. Do not mistake a thorough test suite for a second pair of eyes.

How the premise shapes the design

  • Encrypted files describe themselves in plain ASCII in their first bytes.
  • Each cipher is one self-contained Python file that imports nothing but the standard library. Copy simple_encrypt_chacha20.py onto a bare machine and it decrypts your archives — no build, no install, no sibling files. make test proves this by running each one alone in an empty directory.
  • Every cipher exists twice: once in C, once in that pure-Python file. The test suite asserts the pairs agree byte for byte.
  • The Salsa20 code is byte-identical to NaCl's reference implementation; the ChaCha20 specification is vendored and hash-pinned. Both are re-derivable from upstream with make vendor.
  • Recovering data without this project below is a complete, tested decryptor you could retype from a printout.

Formats

format cipher header nonce written by
v2 ChaCha20, RFC 8439 80 bytes 12 bytes, in the file current default
v1 Salsa20/20, NaCl 64 bytes 8 bytes, in the file --format v1
legacy Salsa20/20, NaCl none 8 bytes, in the key file the original POC

New files get v2. Decryption auto-detects, so you never have to remember which is which — except for legacy files, which have no header to detect and need --legacy.

ChaCha20 is Bernstein's own 2008 revision of Salsa20: same structure, same cost, better diffusion per round. It is chosen here for one reason above all — it is the one the world standardized on, so the odds that a random machine in 2045 has a working, obvious ChaCha20 are much better than for Salsa20. It is in TLS 1.3, OpenSSH, WireGuard, the Linux kernel, OpenSSL, and every language ecosystem worth naming.

Salsa20 remains fully supported and is not deprecated here. Nothing about it is broken.

Quick start

make                                   # builds ./simple_encrypt and the shared library
make test                              # ~160 checks, including RFC 8439's own vectors

python3 simple_encrypt_keygen.py mykey.txt             # a 32-byte key, hex

./simple_encrypt -e --key mykey.txt < secret.tar > secret.tar.se
./simple_encrypt -d --key mykey.txt < secret.tar.se > secret.tar

Same thing from Python, no compiler needed:

python3 simple_encrypt.py -e --key mykey.txt < secret.tar > secret.tar.se
>>> import os, simple_encrypt
>>> key = os.urandom(32)
>>> blob = simple_encrypt.encrypt(key, b'attack at dawn')     # v2 by default
>>> simple_encrypt.identify(blob)
2
>>> simple_encrypt.decrypt(key, blob)                         # format auto-detected
b'attack at dawn'
>>> simple_encrypt.backend()
('c', 'salsa20=c chacha20=c')

Or go straight to one cipher and skip the dispatcher entirely — these need no build step and no other file:

python3 simple_encrypt_chacha20.py -e --key mykey.txt < secret.tar > secret.tar.se
python3 simple_encrypt_salsa20.py  -d --key oldkey.txt --legacy < old.enc > old.tar

Both raw primitives are exposed. crypto_stream is unchanged from the original POC:

crypto_stream(key, nonce, message)            # Salsa20/20, 8-byte nonce
chacha20_stream(key, nonce, message)          # ChaCha20, 12-byte nonce

pip install . installs the pure-Python package — no build tools required. If the shared library from make is present, it is used automatically for speed.

File format, version 2

An 80-byte header followed by the ciphertext:

offset size contents
0 66 ASCII: simple_encrypt/2 chacha20/20 rfc8439 ctr=0 le k=32 n=12@68 hdr=80\n
66 2 NUL padding
68 12 nonce, raw bytes, freshly random for every encryption
80 ... plaintext XOR ChaCha20 keystream(key, nonce), block counter from 0
$ head -c 66 secret.tar.se
simple_encrypt/2 chacha20/20 rfc8439 ctr=0 le k=32 n=12@68 hdr=80

Read that as: format version 2, ChaCha20 with 20 rounds as defined by RFC 8439, block counter starts at 0, little-endian, 32-byte key, 12-byte nonce located at offset 68, header is 80 bytes long.

Because the counter is 32 bits wide, one key/nonce pair covers 256 GiB. The tool refuses to encrypt past that rather than wrapping the counter and silently reusing keystream.

Version 1

Identical in spirit, with Salsa20 and a 64-byte header:

offset size contents
0 56 ASCII: simple_encrypt/1 salsa20/20 ctr=0 le k=32 n=8@56 hdr=64\n
56 8 nonce
64 ... plaintext XOR Salsa20/20 keystream(key, nonce), block counter from 0

Key files

Both v1 and v2 use the same key file: 64 hex digits, a 32-byte key. There is no "v1 key" as distinct from a "v2 key", and simple_encrypt -d works out which container it is looking at by itself. Only the legacy format keeps its nonce in the key file, which is why that one is 80 digits.

digits contents opens
64 32-byte key v1 and v2
80 32-byte key + 8-byte nonce legacy, and also v1/v2 (the nonce is ignored)
python3 simple_encrypt_keygen.py mykey.txt          # 64 digits, for v1 and v2
python3 simple_encrypt_keygen.py --legacy old.txt   # 80 digits
python3 simple_encrypt_keygen.py -                  # to stdout

Key files are created with mode 0600, and an existing one is never replaced without --force — overwriting a key file makes every archive encrypted under it permanently unreadable, which is worse than anything else this project guards against. Whitespace and uppercase are accepted when reading, so a key file you wrote by hand or wrapped across lines still works.

Generating a legacy key file prints a warning, because that layout reuses one nonce for every archive written with it. It exists to re-create a lost key file for archives that already exist, not to make new ones.

Why the nonce is in the file

It must be different for every encryption. In the original POC it lived in the key file, which meant every file encrypted with a given key file shared a nonce — and two messages under the same key and nonce XOR to the XOR of their plaintexts, which unravels both. That was the most serious problem with the POC, and it is why the format changed.

Reading files from the original POC

Old archives have no header and take their nonce from the 80-hex-digit key file. They still work:

./simple_encrypt -d --key data/example_key.txt --legacy < data/Lenna.png.encrypted > Lenna.png

make test verifies this against data/Lenna.png.encrypted, which was encrypted years ago: the current build re-encrypts Lenna.png to a byte-identical copy of it. That test is the whole thesis of the project, so it runs every time.

Recovering data without this project

The easiest route is to copy one file. simple_encrypt_chacha20.py (for v2) and simple_encrypt_salsa20.py (for v1 and legacy) each depend on nothing but the Python standard library:

python3 simple_encrypt_chacha20.py -d --key mykey.txt < secret.tar.se > secret.tar
python3 simple_encrypt_salsa20.py  -d --key oldkey.txt --legacy < old.enc > old.tar

make test copies each of those into an empty directory and runs it there, so the no-dependencies claim is checked rather than asserted.

If even that is gone, the following is enough. It handles all three formats, uses nothing but the standard library, and is extracted from this README and executed by make test — so it is known to work, not merely believed to.

import sys
M = 0xFFFFFFFF
rotl = lambda v, n: ((v << n) | (v >> (32 - n))) & M
words = lambda b: [int.from_bytes(b[i:i+4], 'little') for i in range(0, len(b), 4)]

def salsa_qr(x, a, b, c, d):
    x[b] ^= rotl((x[a] + x[d]) & M, 7);  x[c] ^= rotl((x[b] + x[a]) & M, 9)
    x[d] ^= rotl((x[c] + x[b]) & M, 13); x[a] ^= rotl((x[d] + x[c]) & M, 18)

def salsa_block(key, nonce, ctr):            # Salsa20/20 -- formats v1 and legacy
    k, c = words(key), words(b'expand 32-byte k')
    n = words(nonce + ctr.to_bytes(8, 'little'))
    j = [c[0], k[0], k[1], k[2],  k[3], c[1], n[0], n[1],
         n[2], n[3], c[2], k[4],  k[5], k[6], k[7], c[3]]
    x = list(j)
    for _ in range(10):
        salsa_qr(x,0,4,8,12);  salsa_qr(x,5,9,13,1)
        salsa_qr(x,10,14,2,6); salsa_qr(x,15,3,7,11)
        salsa_qr(x,0,1,2,3);   salsa_qr(x,5,6,7,4)
        salsa_qr(x,10,11,8,9); salsa_qr(x,15,12,13,14)
    return b''.join(((x[i] + j[i]) & M).to_bytes(4, 'little') for i in range(16))

def cc_qr(x, a, b, c, d):
    x[a] = (x[a] + x[b]) & M; x[d] = rotl(x[d] ^ x[a], 16)
    x[c] = (x[c] + x[d]) & M; x[b] = rotl(x[b] ^ x[c], 12)
    x[a] = (x[a] + x[b]) & M; x[d] = rotl(x[d] ^ x[a], 8)
    x[c] = (x[c] + x[d]) & M; x[b] = rotl(x[b] ^ x[c], 7)

def cc_block(key, nonce, ctr):               # ChaCha20, RFC 8439 -- format v2
    j = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574] + words(key) + [ctr] + words(nonce)
    x = list(j)
    for _ in range(10):
        cc_qr(x,0,4,8,12);  cc_qr(x,1,5,9,13)
        cc_qr(x,2,6,10,14); cc_qr(x,3,7,11,15)
        cc_qr(x,0,5,10,15); cc_qr(x,1,6,11,12)
        cc_qr(x,2,7,8,13);  cc_qr(x,3,4,9,14)
    return b''.join(((x[i] + j[i]) & M).to_bytes(4, 'little') for i in range(16))

# usage: python3 recover.py ENCRYPTED_FILE KEY_FILE > PLAINTEXT
raw = bytes.fromhex(''.join(open(sys.argv[2]).read().split()))
key, data = raw[:32], open(sys.argv[1], 'rb').read()
if data[:16] == b'simple_encrypt/2':
    block, nonce, body = cc_block, data[68:80], data[80:]
elif data[:16] == b'simple_encrypt/1':
    block, nonce, body = salsa_block, data[56:64], data[64:]
else:
    block, nonce, body = salsa_block, raw[32:40], data      # the original POC
out = bytearray()
for i in range(0, len(body), 64):
    out += bytes(a ^ b for a, b in zip(body[i:i+64], block(key, nonce, i // 64)))
sys.stdout.buffer.write(bytes(out))

Other routes to the same bytes, in rough order of convenience:

  1. OpenSSL, for v2 files. Its ChaCha20 IV is the 4-byte little-endian counter followed by the 12-byte nonce, so with the nonce at offset 68:
    tail -c +81 file.se | openssl enc -chacha20 -K $(cut -c1-64 key.txt) \
        -iv 00000000$(dd if=file.se bs=1 skip=68 count=12 2>/dev/null | xxd -p)
    
  2. libsodium, which covers both ciphers: crypto_stream_chacha20_ietf_xor for v2, crypto_stream_salsa20_xor for v1 and legacy. Both are bit-compatible with these formats.
  3. The specifications. RFC 8439 for ChaCha20 — vendored at external/rfc8439/, and mirrored on thousands of servers as an IETF standards document. https://cr.yp.to/snuffle.html for Salsa20. Both are small enough to reimplement directly, which is the reason they were chosen.
  4. The NaCl tarball, from https://nacl.cr.yp.to/install.html or any mirror, for Salsa20. You need exactly two files: crypto_stream/salsa20/ref/xor.c and crypto_core/salsa20/ref/core.c. make vendor does this automatically.

Whichever route you take, check it against tests/vectors.json — or, better for ChaCha20, against the vectors in RFC 8439 sections 2.3.2 and 2.4.2 — before trusting it with anything.

Advice for the archive itself

Store a copy of this README and your key file separately from the encrypted data, on different media. The cipher is the part least likely to fail you; the key is the part most likely to.

Vendoring

make vendor          # everything below, from upstream (needs network)
make vendor-spec     # just RFC 8439
make verify-vendor   # offline check that the local copies are still pristine
make vendor-refresh  # re-record SHA256SUMS (only when you mean to)

Two different things are pinned, for the same reason — so neither the algorithm nor its definition can drift away from what this code was written against:

  • external/nacl/ — Bernstein's Salsa20, byte-for-byte from the 2011 release, verified against a SHA-256 of the whole tarball. Sources are kept unpatched by design; see external/nacl/README.md for why, and for how to add a patch if you ever genuinely need one.
  • external/rfc8439/ — the ChaCha20 specification. The RFC publishes pseudocode rather than a liftable C file, so chacha20.c was written from this document, which is therefore pinned just as tightly. See external/rfc8439/README.md.

Tests

make test

.github/workflows/ runs the same suite monthly across compilers, operating systems, Python versions, and a big-endian machine, plus a monthly check that the vendored upstreams still exist and still match. Both can also be started by hand from the Actions tab.

  • RFC 8439's published vectors — the strongest anchor here. The expected values are not generated by this project; they are parsed at test time out of the hash-pinned RFC in external/rfc8439/, and both ChaCha20 implementations must match them.
  • OpenSSL — a fourth implementation, written by people with no connection to this project, checked at several lengths and counter offsets when openssl is installed.
  • Known-answer vectors (tests/vectors.json) — the regression net. If a future compiler or refactor changes a keystream by one bit, this catches it before your archives become unreadable.
  • C vs pure Python — checked at every size around the 64-byte block boundary and across counter carries, for both ciphers. Each pure-Python implementation was written from its specification rather than translated from the C, so agreement is real evidence.
  • Golden files — data/Lenna.png.encrypted (legacy, encrypted years ago), data/Lenna.png.se.v1, and data/Lenna.png.se (v2) must all still decrypt, and must be reproduced byte-for-byte when re-encrypted with the same nonce.
  • Standalone modules — each cipher file is copied alone into an empty directory and run from there, proving it needs no sibling file and no compiled library. The dispatcher is separately checked to produce byte-identical output to the modules it delegates to.
  • Key generation — generated files must be the right length, lowercase hex, mode 0600, accepted by every tool, different each time, and never silently overwritten.
  • Counter exhaustion — ChaCha20's 32-bit counter must refuse to wrap rather than silently reuse keystream.
  • Pipe handling — cat big | simple_encrypt must not lose data. It used to: the original treated any short read() as end of input and silently truncated piped input to one pipe buffer, exiting 0.

Limitations you should know about

These are summarised in Read this first; this is the long version.

There is no integrity check. Both ciphers here are stream ciphers and nothing more. A flipped bit on aging media decrypts to a flipped bit in your plaintext, with no warning; an attacker who can modify the file can flip chosen bits in the plaintext without knowing the key. For archival storage this is the most significant remaining gap. Storing a sha256sum of the plaintext alongside the archive covers accidental corruption; covering deliberate tampering needs a MAC.

This was tried and deliberately backed out. ChaCha20-Poly1305 is the obvious answer — it is specified in the RFC already vendored here, and Poly1305 sits in the same NaCl tarball as Salsa20, so it would have added no new dependency and no new trust root. It was dropped because it fails the one test this project applies to everything: could I rebuild the decryptor from a published spec in an afternoon? Poly1305 is a second primitive with its own arithmetic, and the pad-and-length framing around it is fiddly to get right from prose. That roughly doubles what a future reader has to reconstruct, to buy detection of a problem that error-correcting codes handle better anyway.

If you want repair rather than detection — which is what actually helps against bit rot — par2create over your archives is the right tool, and it composes with this one. If you want authentication, use age.

The key file is plain hex on disk, with no passphrase and no key derivation. That is deliberate — a KDF is one more thing that has to still exist later — but it means the key file is as sensitive as the data.

Everything is read into memory. Fine for documents and photographs, not for disk images. There is no streaming mode.

Two platforms have been observed running this: macOS/arm64/clang and Debian/s390x/gcc, the latter confirming that big-endian machines produce identical bytes. CI is configured for more — see Status — but has not run on a remote yet. If you build it somewhere else, make test is the thing to run, and a report either way would be useful.

No versioned releases yet, and the on-disk formats have only been exercised by their author. v1 and v2 are frozen in the sense that the test suite holds them to golden files, but they have not been through contact with other people's data.

Layout

simple_encrypt_chacha20.py  ChaCha20 + the v2 container + a CLI.  Stands alone.
simple_encrypt_salsa20.py   Salsa20 + the v1 and legacy containers + a CLI.  Stands alone.
simple_encrypt.py           Works out which format a file is and dispatches.  No crypto.
simple_encrypt_keygen.py    Generates key files.  Stands alone.

main.c                      C CLI: reads stdin, writes stdout
chacha20.c / .h             ChaCha20 in C, written from RFC 8439
external/nacl/              pristine NaCl Salsa20 + hand-written shim headers
external/rfc8439/           the hash-pinned ChaCha20 specification

tests/run_tests.py          the suite behind `make test`
tests/vectors.json          known-answer vectors; do not regenerate to pass a test
tests/make_vectors.py       how those vectors were produced
.github/workflows/ci.yml       the suite across OS, compiler, Python, endianness
.github/workflows/upstream.yml monthly: are the vendored upstreams still there?
data/                       golden files, and an example key (obviously not secret)

The three Python files are arranged so that no single one of them has to be understood in full to recover data. Each cipher file is complete on its own — cipher, container, and command line, about 250 lines including the commentary — and simple_encrypt.py contains no cryptography whatsoever, only the logic that reads a file's magic bytes and picks a module. If you only ever read one file, read the one matching your archive's header.

PyPI

I wanted to publish it to PyPI under simple_encrypt, but the name sounds too similar to something else, so I published it under archivalseal.

You can install it like that:

pip install archivalseal

Licence

MIT, see LICENSE. The vendored NaCl sources are public domain.

Metadata

Release files for archivalseal 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distribution (wheel)

Table of built distributions (wheels) for archivalseal 1.0.0
File Interpreter ABI Platform
archivalseal-1.0.0-py3-none-any.whl Python 3 none any Details

Release files / archivalseal-1.0.0-py3-none-any.whl

Download URL archivalseal-1.0.0-py3-none-any.whl
Size 23.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3e2c42ecef74de4ac694aceb25ba76ce72bfcd2fd057f4dccbff721274155239
BLAKE2b-256 checksum
How to use checksums
c3992d1e564a9d1c2f3bde8d7ec2376d14a1f74c6c1ba7167fb0e51f8806e020
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.7

Security for archivalseal 1.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.0.0 This release

1 release file No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page