← Back to blog

We run deny.sh on deny.sh

Launch week · 4 July 2026 · 7 min read

A credible security vendor uses its own product, on its own data, in production. Anything less is theatre. This write-up documents the internal control we ran against ourselves: we took a real operator credential (the GitHub recovery codes for our organisation account, the file that restores 2FA access if a hardware key is lost), migrated it into the deniable primitive we ship to customers, and ran a three-drill attack pass against it. The numbers and terminal output below are the actual artefacts of that pass.

This uses the same cryptographic primitive that powers the hosted runtime and the same SDK functions exposed in the public Apache-2.0 library. The wrong-password observability and at-rest properties are the properties customers receive from that primitive.

The setup

Operator recovery codes are the single highest-leverage credential in any organisation's infrastructure stack. They sit one layer above MFA: if the hardware keys break and the codes leak, the entire identity boundary fails open. The conventional storage answer is some combination of password manager, hardware vault, or a small encrypted file in an operator-controlled location, all of which assume the attacker never holds both the ciphertext and the right password at the same time.

The deniable primitive removes that assumption. Two passwords protect one ciphertext, which is paired with a small control file. The same passwords plus the real control file decrypt to the real plaintext. The same passwords plus a different control file decrypt to a plaintext you chose. An attacker holding the ciphertext and any one control file cannot tell, from the bytes alone, whether they are looking at the real artefact or a decoy.

For this exercise we used our internal credential-management tooling, which wraps the public SDK directly: the same encrypt, decrypt, and generateDeniableControl functions that ship with the Apache-2.0 release. We encrypted the recovery codes under two operator-held passwords, then generated a sibling decoy block: eight lines of plausible-but-synthetic codes formatted the same way GitHub formats theirs (xxxxx-xxxxx hex pairs). Same passwords, second control file, decoy plaintext.

The on-disk layout for one item:

~/.deny/items/GITHUB_RECOVERY_CODES.ct           338 B
~/.deny/items/GITHUB_RECOVERY_CODES.real.ctrl   4096 B
~/.deny/items/GITHUB_RECOVERY_CODES.decoy.ctrl  4096 B
~/.deny/items/GITHUB_RECOVERY_CODES.meta.json    412 B

Three observations. The ciphertext payload is compact, just the encrypted recovery codes plus a 48-byte header. The two control files are byte-identical lengths. And file(1) classifies both as featureless data: no header, no magic bytes, no metadata that distinguishes one from the other.

Drill 1. The wrong-password observability test

This is the test that matters most for deniability. If a normal password-protected file leaks, an attacker who tries to brute force it knows when they've succeeded: a wrong password gives an error or rejects the unlock, a right one opens the file. That signal is what makes brute force tractable. Try 10,000 passwords, the right one announces itself.

With deniable encryption, that signal should not exist. A wrong password pair produces output silently, with no error and no success tell. Where the attacker supplies a valid decoy control file, that output is a plausible decoy; with arbitrary wrong passwords it is indistinguishable random-looking bytes. Either way there is nothing to brute force against.

We ran the decryption pipeline five times. Once with the real password pair, four times with deliberately incorrect passwords drawn from a mix of pattern and random sources. Same binary, same ciphertext, same control file. The output of each run, truncated to 48 bytes for readability:

$ for i in 1 2 3 4 5; do echo "--- attempt $i ---"; ddv get GITHUB_RECOVERY_CODES | head -c 60 | xxd | head -3; done --- attempt 1 --- (real passwords) 00000000: 2320 4769 7448 7562 2032 4641 2052 6563 # GitHub 2FA Rec 00000010: 6f76 6572 7920 436f 6465 7320 2864 656e overy Codes (den 00000020: 792d 7368 2d63 7279 7074 6f20 6f72 6729 y-sh-crypto org) --- attempt 2 --- (wrong passwords) 00000000: 5a31 3357 201a c606 5f86 a90c 86b3 dd55 Z13W ...._.....U 00000010: 89a5 f9b1 ae1d eedf 88e5 f853 8026 ff03 ...........S.&.. --- attempt 3 --- (different wrong passwords) 00000000: 355b 5f82 363b 6b2a 5da5 375e 8507 1285 5[_.6;k*].7^.... 00000010: 09a8 f498 24eb f89e fea5 65af 2100 287a ....$.....e.!.(z --- attempt 4 --- (different wrong passwords) 00000000: 0f55 e60f 4f6b 1f4b a8a3 d67f c148 230a .U..Ok.K.....H#. 00000010: 3c14 76b5 b60d 179b fcdd af7c 67b0 1250 <.v........|g..P --- attempt 5 --- (different wrong passwords) 00000000: 8785 10f5 5556 ffb0 ca8f 2efa 42b7 d7cf ....UV......B... 00000010: 5338 0c23 8913 210c c6a8 f5d8 34ce 258a S8.#..!.....4.%.

Attempt 1 returned the real plaintext, # GitHub 2FA Recovery Codes (deny-sh-crypto org). Attempts 2 through 5 each returned independent 48-byte sequences of unrelated content. No error code, no "invalid password" string, no retry counter, no rate-limit message. The decryption layer cannot signal failure because, mathematically, it has no notion of failure: every key produces an output, every output is a valid byte sequence.

This is the property that breaks conventional brute-force. An attacker without prior knowledge of the expected plaintext structure has nothing to test their guesses against. Even with prior structural knowledge, the deniable layer means any output matching that structure could itself be the decoy, the real artefact, or a second decoy the operator generated and never disclosed. The "right-answer" signal that anchors a brute-force attack is absent by construction.

Drill 2. The cost of guessing

The deniability property defends against an attacker holding the ciphertext without the passwords. The complementary question is the cost imposed on a structural attacker, one who has prior knowledge of the expected plaintext format and will pattern-match every decrypt against that format until something plausible emerges.

The key derivation function is Argon2id with parameters t=3, m=64 MiB, p=1, v=0x13. This is the documented interactive default across deny.sh; per-tenant configurations on the hosted runtime can be raised. We measured derivation cost on a single core of our production infrastructure:

$ node -e 'import("hash-wasm").then(async ({argon2id}) => { const crypto = await import("node:crypto"); const salt = crypto.randomBytes(32); const password = crypto.randomBytes(64); const iters = 3; const t0 = process.hrtime.bigint(); for (let i = 0; i < iters; i++) { await argon2id({password, salt, parallelism:1, iterations:3, memorySize:65536, hashLength:32, outputType:"binary"}); } const t1 = process.hrtime.bigint(); const ms = Number(t1 - t0) / 1e6; console.log(`${iters} derivations in ${ms.toFixed(1)}ms`); })' 3 derivations in 1324.8ms -> 441.59ms each -> 2.26 guesses/sec

2.26 derivations per second per core. Argon2id's memory-hardness penalises the parallelism that breaks faster KDFs because each concurrent guess needs a 64 MiB working set. Applied to two passwords, the floor of an attacker's task looks like this:

~39,500 years to exhaust the entire space of 8-character lowercase + digit passwords (36⁸ ≈ 2.8 × 10¹²)
~45 trillion years for a single 12-character mixed-case alphanumeric password (62¹² ≈ 3.2 × 10²¹)

Two passwords double the cost. A motivated attacker who guesses both correctly still has the wrong-password observability problem from drill 1: they don't know they've succeeded. They have to take their candidate plaintext and decide whether it's "the real one" or "a decoy designed to look real."

Threat-model note

The numbers above are the floor. Higher Argon2id parameters, especially larger memory settings, raise the attacker's cost by orders of magnitude. The configuration we measure here is the interactive default, optimised for commodity client hardware. Enterprise tenants who accept higher decrypt latency in exchange for higher attacker cost can request elevated parameters at provisioning. None of these tradeoffs affect the deniability property in drill 1; they only affect the difficulty of the password layer.

Drill 3. The at-rest indistinguishability test

The last drill: prove that an attacker who seizes the encrypted files at rest, with no passwords yet, learns nothing about which file is real.

$ xxd -l 128 ~/.deny/items/GITHUB_RECOVERY_CODES.real.ctrl 00000000: 1617 7ad3 604b 1b20 a477 963c 0529 bc0d ..z.`K. .w.<.).. 00000010: c345 0c15 c533 f8cd 4fba 217b 00f0 4181 .E...3..O.!{..A. ... $ xxd -l 128 ~/.deny/items/GITHUB_RECOVERY_CODES.decoy.ctrl 00000000: 6816 7ad3 700a 3970 e512 8769 417f c846 h.z.p.9p...iA..F 00000010: d375 5913 9b68 f88a 05ab 5b1e 5df0 51c0 .uY..h....[.].Q. ... $ stat -c '%s %n' ~/.deny/items/GITHUB_RECOVERY_CODES.{real,decoy}.ctrl 4096 ~/.deny/items/GITHUB_RECOVERY_CODES.real.ctrl 4096 ~/.deny/items/GITHUB_RECOVERY_CODES.decoy.ctrl $ file ~/.deny/items/GITHUB_RECOVERY_CODES.{real,decoy}.ctrl ~/.deny/items/GITHUB_RECOVERY_CODES.real.ctrl: data ~/.deny/items/GITHUB_RECOVERY_CODES.decoy.ctrl: data

Identical lengths. Both classified as featureless data. No magic bytes, no headers, no version markers, nothing that file(1), binwalk, or any structural analyser can use to pick the real one. The bytes are statistically indistinguishable from random.

This is the property that makes the seizure case operationally meaningful. An attacker who exfiltrates one ciphertext and one available control file cannot determine from the bytes whether that control file is real or decoy. The filenames used in this writeup (.real.ctrl, .decoy.ctrl) are documentation labels only; in a production deployment the two control files sit across separated locations and trust boundaries, with the decoy positioned as the obvious one to find and the real one held behind an independent control. The indistinguishability of the control bytes is what this drill demonstrates; the separation of where they sit is a deployment property handled by the runtime.

What changed about our internal posture

The exercise drove a permanent change to our operational baseline. The plain-on-disk GitHub recovery codes are gone, shredded, and the real codes now exist only as a 338-byte ciphertext under the deniable primitive. Cold recovery was verified end-to-end (real passwords, real control file, real plaintext) before the original file was destroyed, with no fallback path left in place.

The same migration is in progress for the rest of our operator-class credential set: npm publish recovery codes, the GitHub personal access tokens used by CI, the Stripe live-mode root recovery, the AWS root account recovery codes, and the wallet seed phrases backing the company's on-chain treasury. Each is moving under the same primitive with its own dedicated decoy, generated to match the expected format of the real artefact closely enough that an attacker cannot triage real-vs-decoy on shape alone.

The compound effect is operational: every credential that, until last month, sat in some combination of password manager, plain-on-disk, or single-password encrypted vault is now stored under a primitive where seizure plus password exposure still does not yield the real artefact unless the attacker also identifies the correct control file. We could not credibly position deny.sh as enterprise infrastructure while leaving our own credentials on the wrong side of that line. They are now on the same side as our customers'.

Verifiability

Every number, terminal output, and behavioural claim in this post is reproducible against the public SDK. The cryptographic engine is a single source file (core.ts in the deny-sh-crypto/deny-sh repository), licensed Apache-2.0, with the algorithm written out in prose at deny.sh/verify and a public verification suite that executes in-browser.

To reproduce the drills against an installation under your control: install the deny.sh CLI, encrypt a small file with two passwords, generate a decoy with deny-sh decoy, and re-run the wrong-password loop and the Argon2id timing on your own hardware. The drill-1 and drill-3 outputs are deterministic up to entropy: the shape will reproduce exactly. Drill-2 numbers depend on the host CPU; expect a small constant multiplier in either direction.

For prospective enterprise customers running their own diligence, we will provide cryptographic reference vectors, the source of the timing harness, and a sandbox tenant on the hosted runtime against which the same drills can be run independently. The published SDK is the same primitive used by the hosted runtime, so reviewers can reproduce the cryptographic claims locally with a Node installation.

Run the same drill against your infrastructure

The CLI, the SDK, and the verification suite are open and free. The deniable primitive is consistent across the hosted runtime, the JavaScript SDK, the Rust crate, the Python package, and the Go module. Enterprise deployments add per-tenant isolation, audit logging, and configurable Argon2id parameters; the underlying cryptography is the same code path as the public SDK.

Install the CLI · Enterprise deployment · Read the algorithm in prose

Sharing the post: deny.sh/blog/we-run-deny-sh-on-deny-sh