2026-07-20
Proving NearSeal's age-encryption.org format is real, interoperable age
NearSeal added a second output format this week: alongside its original format, you can now
opt into real age-encryption.org output — the format
built by Filippo Valsorda (FiloSottile), Go's cryptography lead. The point of offering it is
interoperability: a file encrypted as age should open in the official age CLI, in
rage, in any other age-compatible tool — not just on this website. That's a claim
worth being suspicious of. It's easy to build something that calls itself "age" and only actually
talks to itself. This post is about how we tried to prove ours doesn't do that, using a real test
script (tests/live-check-age.mjs) that reaches outside our own code to check.
Two formats, on purpose, with different tradeoffs
NearSeal's original format — call it NSEAL1 after its magic bytes — is AES-256-GCM with a key
derived from the passphrase via PBKDF2-SHA256 at 220 iterations (1,048,576, comfortably
above OWASP's 2023 minimum recommendation of 600,000). It's solid, standard cryptography, but the
container shape is ours: only NearSeal-compatible tools can open an NSEAL1 file. The new age
format is different in every specific way that matters for interop: it comes from FiloSottile's
own age-encryption npm package (pure JS, built on noble's crypto primitives plus
WebCrypto — no server calls, matching every other privacy claim this app makes), it derives its
key with scrypt rather than PBKDF2, and it encrypts with ChaCha20-Poly1305 inside age's own STREAM
chunked-AEAD construction — deliberately not AES-256-GCM. That distinction isn't
pedantry: our own product copy is careful to only claim "AES-256" for the NearSeal format and not
imply it for age, because it would be a false claim otherwise. NearSeal emits the binary
(unarmored) age container by default — the same shape age -e -p produces on the
command line — though decrypt also accepts ASCII-armored input, since that costs nothing and only
adds interop surface. On decrypt, the app auto-detects which format a file is by reading its
first bytes (NSEAL1's magic vs. age's own age-encryption.org/v1 header, or the
armored PEM-style header) — you never have to tell it which kind of file you're opening.
Why "our library agrees with itself" isn't proof
The easiest interop test to write, and the least convincing one, is: encrypt with your app,
decrypt with your app, check the bytes match. That only proves your encrypt and decrypt functions
are inverses of each other — a NearSeal-specific bug that happened to be symmetric (present in
both directions) would sail through a test like that undetected, and so would a subtly
non-standard container that only this app's own code knows how to read. If the entire proof chain
never leaves crypto-core.js, "genuinely spec-compliant age" is still just a claim, not
a demonstrated fact. So live-check-age.mjs is built around a stricter standard:
line up progressively more independent implementations against the same bytes, and see how far
down that chain the file still opens.
Check 1 and 2: the app still works, including the new format
The script starts with two checks that are really regression guards, not interop proof. First,
the pre-existing NSEAL1 format is driven through the app's real UI — a real headless Chromium
page (Playwright), a real file input, a real Web Worker doing the crypto, a real download —
encrypt then decrypt, and the result is asserted byte-identical to the source file. That's there
to catch the new format-choice radio buttons breaking the format that already shipped. Second, the
same round trip runs again with the age radio button selected instead of the default NearSeal one,
and again the decrypted bytes must exactly match the original. Bundled into that same check: when
the app is handed an already-encrypted file, a .mode-badge element in the UI switches
from "encrypt" to "decrypt" purely by recognizing the file's own bytes — not its extension. That
matters for the interop story specifically, because it means a file made by some other age tool,
with some other extension, would still be recognized correctly.
Check 3: a completely separate code path reads the same bytes
This is the first check that actually starts to prove something. The age-encrypted bytes used
here were produced entirely through the app's real surface — real browser, real Web Worker, the
real bundled age-encryption build running inside it. Those bytes are then handed to a
plain Node script that imports only the standalone age-encryption npm
package's Decrypter class directly — zero import of this app's own
crypto-core.js, no browser anywhere in the process. If NearSeal's own
decryptAgeBytes() had some subtle bug that only its own paired encrypt function
happened to produce output compatible with, this step is exactly the kind of thing that would
catch it: the two round-trip checks above would still pass (self-consistent), but this
independent decrypt would fail. It doesn't — the standalone library decrypts the browser's output
exactly. The script also checks something more literal: that the raw bytes the browser produced
start with the exact ASCII string age-encryption.org/v1, the real, standard age
magic header defined by the spec — not some NearSeal-flavored variant of it.
Checks 4 and 5: a completely different language, and the reverse direction
The strongest available proof goes one step further than "a different JS library agrees with
our JS library": it hands the exact same browser-produced file to the real, official
age CLI — github.com/FiloSottile/age,
the reference implementation, written in Go, a completely separate codebase and language from the
npm package used everywhere else in this test. If the Go implementation can decrypt a file our
browser's JS produced, that's no longer "our dependency is internally consistent" — it's
cross-implementation, cross-language proof that the output is real, spec-compliant age. Driving
this is fiddly: the official CLI refuses to read a passphrase from piped stdin (a deliberate
security choice on its part), so the test script spawns it behind a real pseudo-tty using a small
Python pty helper and answers its interactive passphrase prompt by watching for the
word "assphrase" in the CLI's own output stream. Then the script runs the same trick in reverse
for check 5: it uses the official CLI's -p flag (which is Go, not our code, prompting
twice — entry and confirmation) to encrypt a file that has never touched NearSeal in any way, and
feeds that file into NearSeal's own web UI. It decrypts correctly, auto-detected exactly like any
other age file. That closes the loop in both directions: not just "files made here open
elsewhere," but "files made elsewhere open here." Every step in every one of these five checks
also runs under Playwright's network-request capture, and the script asserts zero requests left
the local test server's origin throughout — the same no-network guarantee this app makes for its
original format, holding for the new one too.
Being honest about what depends on an installed CLI
Checks 4 and 5 are the strongest evidence in this whole test, and they're also the two checks
that can't run everywhere. The script's own comment says it plainly: the real age
CLI "happens to be installed on this system," and if it isn't — in a stripped-down CI image, say,
or a machine that's never had Go tooling set up — checks 4 and 5 print SKIP: real age CLI
not usable in this environment and the run still exits clean, rather than failing on an
environment gap that has nothing to do with whether the code is correct. That's a real limitation
worth stating plainly rather than glossing over: the airtight, cross-language version of this
proof is conditional on the CLI being present wherever the check runs. What doesn't depend on
that at all is check 3 — the standalone age-encryption library check always runs, has
no external binary dependency, and on its own already demonstrates genuine interoperability with
an implementation this app's own code never touches. Checks 4 and 5 are the bonus round, not the
foundation.
Why bother with a second, less "ours" format at all
The header comment in crypto-core.js puts the product reasoning in one line:
offering age is about "your files stay near you," in contrast to the implicit tradeoff of the
default NSEAL1 format, which is closer to "your files stay locked to this website." Neither is
wrong — NSEAL1 is solid AES-256-GCM with strong KDF parameters, and for someone who only ever
plans to decrypt with NearSeal again, it's a perfectly good default (and stays the pre-checked
default in the UI). But some people encrypting a file don't want that dependency: a backup meant
to sit untouched for years, a file handed to someone who may never visit this site, a personal
archival habit that shouldn't assume any particular web app stays online indefinitely. For those
cases, a file that any age-compatible tool can open — verified here against the actual reference
implementation, not just asserted — is a meaningfully different guarantee, and it's the one this
test script exists to check isn't just marketing language.
The honest summary
"We added an age format" and "we added a genuinely interoperable age format" are different claims, and only the second one is worth making. The difference between them, in this codebase, is five concrete checks that get progressively harder to fake: the app doesn't break its own existing format, the new format round-trips through the app itself, a totally separate implementation of the same npm dependency can read the app's output with zero app code involved, the real reference Go implementation can read it too when available, and files that implementation produces open correctly here. Four of those five ran clean in this environment; the fifth and sixth-strength CLI-dependent checks are honestly labeled as conditional rather than quietly skipped without a trace. That's the actual state of the proof — not "trust us," but "here's exactly how far outside our own code this was checked, and where that checking currently stops."