NearSeal

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."

Sponsored
← NearSeal

This page shows ads only if you consent.