← Septima blog

The passwords file is just a file

2026-08-16 · design · shipped in v0.5.0

Septima has been able to batch-compress for a while: stage twenty folders, flip create a separate archive for each item, and you get twenty archives instead of one big one. Adding encryption to that turns out to be a more interesting problem than adding encryption to a single archive, and not for cryptographic reasons.

One password across twenty archives is one compromise across twenty archives. Twenty different passwords means twenty things to remember, and the moment you write them down carelessly — a passwords.txt next to the .7z files — you've undone the encryption you just paid for. So the interesting question was never "which cipher." It was: where do the passwords go, and who is responsible for them?

The integration trap

The obvious answer is to integrate. Talk to KeePassXC. Talk to Bitwarden. Write to the GNOME keyring over D-Bus.

Each of those is a separate API, a separate failure mode, and a separate thing to keep working forever. Worse, inside a Flatpak each one is a hole argued for in the sandbox — and the whole premise of this app is that it reaches your files through portals and nothing else. Pick two integrations and you've told everyone using the third that their tool doesn't count.

So Septima integrates with nothing, on purpose. It writes a passwords file and hands it to you. You put it wherever you keep secrets — a password manager, an encrypted volume, a printed page in a safe. Every vault is supported by default, because a file is the one interface they all already have.

Why it's JSON — and why CSV is still in there

The primary use of this file is a human reading it. You open it, you look at what you just made, you paste entries somewhere. Columns are hostile to that; self-describing keys are not. Each entry records the archive name, what it was made from, the password, a SHA-256, when it was written, and the cipher in plain words:

{
  "archive": "tax-2024.7z",
  "source": "tax-2024",
  "password": "…64 characters…",
  "sha256": "…",
  "created": "2026-08-16T10:14:02Z",
  "encryption": "7z, AES-256, encrypted headers"
}

That last field is the one that earns its place. Six months later nobody remembers whether the file names were encrypted too — so the file says so, in words, rather than making you reconstruct it from the app version.

The CSV writer that existed first didn't get deleted. It stays as an export path, because KeePassXC and Bitwarden bulk-import CSV and not arbitrary JSON. Moving the primary format to JSON also retired a whole edge case: in a spreadsheet, a field beginning with =, +, - or @ is a formula, not text. Generated passwords are drawn from an alphanumeric set — 62 characters, no punctuation — which keeps them clear of that, and of shell quoting, at the cost of a few bits per character that 64 characters of length make irrelevant.

Ordering is the crash-safety story

Here's the failure that decides the whole write path: the machine dies halfway through archive eleven. If passwords were recorded as each archive finished, you now own eleven encrypted archives and ten passwords. Archive eleven is scrap — indistinguishable from random noise, permanently.

So the rule is inverted. Every password is generated and written to disk before the first 7zz ever spawns. An archive can never exist whose password isn't already recorded. As each job completes, its checksum and completion time land in the file and it's rewritten — one serialized writer, atomic temp-and-rename each time, so a crash mid-write leaves the previous good file rather than half of a new one.

The cost is that an interrupted run leaves passwords for archives that were never created. That's the right direction to fail in: a password with no archive is litter, an archive with no password is a loss.

Protecting the file itself

A plain file full of passwords is fine while it's in transit from Septima to your vault, and a liability the moment it isn't. So the file can be protected with a password of its own — symmetric OpenPGP, via the gpg that's already in the GNOME runtime:

gpg --batch --yes --quiet --symmetric --cipher-algo AES256 \
    --pinentry-mode loopback --passphrase-fd 0 --no-symkey-cache \
    --output - -

Symmetric only. No keys, no keyring, no reaching out to the host's ~/.gnupg. The output is an ordinary OpenPGP file that any gpg on any machine opens with the passphrase — which matters, because the worst possible property for a disaster-recovery file is that it can only be opened by the app that wrote it. The passphrase and the plaintext both travel in over stdin, so neither appears in argv, the environment, or a temp file.

The choice happens before the batch runs, in the dialog, together with the passphrase. That's not a UI preference — it's what keeps a protected run from having a plaintext moment on disk. If the decision came afterwards, the file would have to exist in the clear first, and "we deleted it" is not the same as "it was never written." Decline protection and you get a plain-words warning about what you're leaving next to the archives, which is the honest version of a default.

The gpg-agent detour

The first protected run worked, and then the app wouldn't exit. Window closed, process gone, flatpak run still sitting there. The cause: gpg's first call had daemonized a gpg-agent inside the sandbox, and bwrap waits for every process in its namespace.

The tempting fix — --no-autostart, symmetric ops don't need an agent — was wrong, and instructively so. In GnuPG 2.4 the loopback-passphrase machinery lives inside the agent, so agentless symmetric encryption fails outright with "no gpg-agent running." The first test that seemed to pass had been borrowing an ambient socket directory: luck, not design.

What it did prove is that the ordering rule works. With gpg broken, protected batches failed before writing a single archive — refusing to start rather than producing files whose passwords couldn't be saved. A feature can be broken and still fail in the right direction.

The real fix is unglamorous: let the agent start as gpg wants, then gpgconf --kill gpg-agent after every operation, success or failure. Nothing lingers to hold the sandbox open, and --no-symkey-cache keeps the passphrase out of the cache the agent briefly has.

The other half: giving the file back

A passwords file you can only read yourself is a notepad. The point is handing it back. Open one in Septima — with the Open button, by dropping it on the window, or from the command line — and instead of trying to browse it as an archive, Septima recognises it, asks for its passphrase if it's protected, and offers to extract everything it lists, each archive with its own recorded password.

Two details from building that side:

And because "extract all twenty" is often not what you want, the picker lists every entry with its password status and the item it came from, with select-all, clear, and a filter once the list gets long.

What's next

The obvious follow-up came from the first person to use it: you're using gpg to set a password, not to encrypt to a key. Correct. A third mode — gpg -e -r <your key> — means nothing to remember at write time, and it only ever touches your public key, so the sandbox stays exactly as clean. The engineering there isn't the encryption, it's the key picker, and the honest trade-off to put in front of you: a password-protected file can be opened anywhere, a key-encrypted one only where the secret key lives. Both are your call, not the app's.

Batch encryption ships in Septima v0.5.0. Install from the signed repo or read the source on GitHub.