Metadata-Version: 2.4
Name: archivalseal
Version: 1.0.0
Summary: A small ChaCha20/Salsa20 file encryptor that depends on an algorithm, not an application
Author: Aleksandr Tarelkin
License-Expression: MIT
Project-URL: Source, https://github.com/newtover/simple_encrypt
Keywords: salsa20,encryption,nacl,archival
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security :: Cryptography
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# simple_encrypt

A small file encryptor built on Daniel J. Bernstein's
[ChaCha20](https://datatracker.ietf.org/doc/html/rfc8439) and
[Salsa20](https://en.wikipedia.org/wiki/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](#limitations-you-should-know-about) — 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](https://github.com/FiloSottile/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](#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

```sh
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:

```sh
python3 simple_encrypt.py -e --key mykey.txt < secret.tar > secret.tar.se
```

```python
>>> 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:

```sh
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:

```python
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 |

```sh
$ 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) |

```sh
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:

```sh
./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:

```sh
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.

```python
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:
   ```sh
   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

```sh
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

```sh
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](#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](https://github.com/FiloSottile/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](#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](https://pypi.org/project/archivalseal/).

You can install it like that:

```
pip install archivalseal
```

## Licence

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