<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
 <channel>
  <title>Frank DENIS random thoughts.</title>
  <link>https://00f.net</link>
  <description>Frank DENIS blog</description>
  <language>en-US</language>
  <lastBuildDate>2026-09-13T18:52:13+02:00</lastBuildDate>
  <ttl>50000</ttl>
  
  <author>Frank Denis (Jedi/Sector One)</author>
  

  
  <item>
    <title>Semi-public Git repositories</title>
    <link>https://00f.net/2026/09/09/semi-public-git-repositories/</link>
    <pubDate>2026-09-09T00:00:00+02:00</pubDate>
    <content:encoded><![CDATA[ <p>Okay, maybe I’m just messy, but I’m sure the story below will resonate with a lot of developers.</p>

<p>It often starts with a small file I’m not planning to commit.</p>

<p>I’m trying something, so I write a test. Or I figure out how to deploy the project to one of my servers and put the commands in a script. It assumes my directory layout, my set of installed tools, and probably something I’ve forgotten to document, but it works for me. That’s all I need from it.</p>

<p>Then I add some notes about things to fix or try, along with an idea for a feature that might go nowhere. None of this is ready to publish, but it belongs with the project, so I keep it in the checkout.</p>

<p>After a while, my checkout looks pretty different from what someone gets by cloning the repository. The public code is there, along with all the little things that help me maintain it.</p>

<p>And those little things stick around. They show up every time I run <code class="language-plaintext highlighter-rouge">git status</code>, until I’ve learned to ignore them without really looking. I don’t put them in <code class="language-plaintext highlighter-rouge">.gitignore</code>, because I don’t necessarily want to publish their names either.</p>

<p>I don’t want someone reading a rough idea in my notes and treating it as a promise. Deployment instructions might also have sensitive stuff in them. And honestly, a useful script can be terrible code, which I don’t want to clean up for public review every time I need to use it.</p>

<p>I could add them to <code class="language-plaintext highlighter-rouge">.git/info/exclude</code>, which would make <code class="language-plaintext highlighter-rouge">git status</code> quieter. But then I switch machines, clone the project, and get exactly what I asked Git to keep: the public part. All my useful clutter stayed on the other laptop.</p>

<p>I’d like those files to have a history, and I’d like to push them somewhere and get them back with the rest of my work. Git already does all of this for every other file in the project.</p>

<p>I want to share as much of the project as I can. I also want to be able to save unfinished work without having to explain it to everyone who looks at the repository.</p>

<p>So I started exploring an idea: what if I could check these files into the same repository, but only people with a secret key would get them in their checkout?</p>

<p>I already had a lot of what I needed in <a href="https://github.com/jedisct1/turbocrypt">Turbocrypt</a>, a fast, portable file and directory encryption tool. I use it all the time to back up sensitive files remotely. I also use it to keep some directories on my laptop encrypted, with a password protecting the key. That way, no applications can just read through them unless I step in and unlock them first.</p>

<p>Git encryption has just been implemented, and it changes everything.</p>

<h2 id="getting-private-files-into-git">Getting private files into Git</h2>

<p>Here’s how to add an untracked <code class="language-plaintext highlighter-rouge">NOTES.md</code> and an <code class="language-plaintext highlighter-rouge">ops/</code> directory.</p>

<p>From inside the checkout, generate a key outside the repository and tell Turbocrypt to use it:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> ~/.config/turbocrypt
turbocrypt keygen <span class="nt">--password</span> ~/.config/turbocrypt/secret.key
turbocrypt config set-key ~/.config/turbocrypt/secret.key
turbocrypt git init
</code></pre></div></div>

<p>Then add the files that should stay private:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>turbocrypt git add NOTES.md ops/
git commit <span class="nt">-m</span> <span class="s2">"Add maintainer files"</span>
git push
</code></pre></div></div>

<p>After the push, <code class="language-plaintext highlighter-rouge">NOTES.md</code> and <code class="language-plaintext highlighter-rouge">ops/deploy.sh</code> stay in place, so the notes can be opened in an editor and the script can run as usual.</p>

<p>What actually went into Git is an encrypted copy of each file, under <code class="language-plaintext highlighter-rouge">.enc/</code>. Their names are encrypted, too, along with the list of private paths. Local exclude rules keep the readable copies out of commits, without adding their names to a public ignore file.</p>

<p>Privacy applies to individual paths. It doesn’t force private work into a separate repository or even a dedicated directory. Private source files can sit next to public source files, and a private <code class="language-plaintext highlighter-rouge">AGENTS.md</code> can stay at the repository root, where tools expect to find it.</p>

<p>The checkout keeps its normal layout. Editors, compilers, and other tools just open ordinary files, and there’s no filesystem to mount. Only the encrypted copies stored in Git live under <code class="language-plaintext highlighter-rouge">.enc/</code>. That’s the private overlay I wanted.</p>

<p>New helpers added to <code class="language-plaintext highlighter-rouge">ops/</code> are covered by the directory rule, too. Turbocrypt’s Git hooks update the encrypted copies before commits and refresh the readable files after checkouts, merges, and rebases.</p>

<p>If only private files have changed, run this to update and stage the encrypted copies before committing:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>turbocrypt git encrypt
git commit <span class="nt">-m</span> <span class="s2">"Update maintainer files"</span>
git push
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">turbocrypt git status</code> shows which private files have changed.</p>

<p>On another machine, clone the repository like usual, copy the key over separately, and run this from the checkout:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>turbocrypt git unlock
</code></pre></div></div>

<p>The notes and the deployment script show up at their original paths, ready to use. Their encrypted versions came through the same remote and the same history as the public code.</p>

<p>Someone cloning without the key still gets the public project and can build it or contribute without installing Turbocrypt. They get the encrypted store, too, but the private files don’t show up in their working directory.</p>

<h2 id="making-room-for-another-maintainer">Making room for another maintainer</h2>

<p>My deployment script is probably useless to another maintainer who’s got a different setup. They can keep their own scripts in the same repository with a separate key.</p>

<p>Maintainers don’t have to agree on what belongs in a shared private directory, because each key gets its own area under <code class="language-plaintext highlighter-rouge">.enc/</code>.</p>

<p>Turbocrypt restores the files belonging to the checkout’s key and leaves the others encrypted, including their lists of private paths.</p>

<p>So several maintainers can push private files to the same repository, then unlock their own notes and tools in a fresh clone.</p>

<p>They start with <code class="language-plaintext highlighter-rouge">turbocrypt git init --key ...</code> when their key is new to the repository. The next time they clone it, they use <code class="language-plaintext highlighter-rouge">turbocrypt git unlock --key ...</code> to get their files back. Right now, each checkout uses one key.</p>

<p>An entire branch can be private, too. A forthcoming feature can be developed and pushed normally, then revealed once it’s finished.</p>

<p>Sharing a key also lets a team work on a feature before it’s ready to publish. If the code depends on an internal API that hasn’t reached production yet, the team can commit the prototype and its documentation while waiting for the rollout.</p>

<p>When it’s ready, <code class="language-plaintext highlighter-rouge">turbocrypt git rm &lt;path&gt;</code> takes a file out of private management. The readable copy stays on disk, ready for an ordinary <code class="language-plaintext highlighter-rouge">git add</code> and a public commit.</p>

<p>I’ve <a href="/2026/05/17/developping-in-the-open/">written before about what happens when people start picking apart unfinished experiments</a>. You push a branch because it’s convenient, and suddenly people think you’ve announced the future of the project. I’d like to try an idea with another maintainer and drop it if it doesn’t work out. If it does, we can publish it when we’ve got something worth showing.</p>

<h2 id="how-this-compares-to-git-crypt">How this compares to git-crypt</h2>

<p><a href="https://github.com/AGWA/git-crypt">Git-crypt</a> implements a similar idea.</p>

<p>But the differences go much further than hiding names.</p>

<p>A file called <code class="language-plaintext highlighter-rouge">plans-to-replace-the-database.md</code> already tells people a lot, even if they can’t read a word of it. And I wanted my private files to show up alongside the public files, with Git tracking their encrypted copies separately.</p>

<p>Keeping the encrypted copies separate also makes accidentally publishing a private file harder. The readable paths are excluded from Git, and the commit hook refuses a private file that’s also tracked in clear. Git-crypt relies on public <code class="language-plaintext highlighter-rouge">.gitattributes</code> rules, which must be active before a sensitive file is added. Changing those rules can disable encryption.</p>

<p>Turbocrypt uses a fresh random nonce for every encrypted copy. Two files with identical contents don’t produce identical ciphertext. Git-crypt deliberately uses deterministic encryption, so equality is visible.</p>

<p>Turbocrypt also authenticates the relative path with the contents. Moving an encrypted entry to another path is detected, even if both paths use the same key.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left"> </th>
      <th style="text-align: left">Turbocrypt</th>
      <th style="text-align: left">git-crypt</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">Private paths</td>
      <td style="text-align: left">HCTR2 encrypts file and directory names. Each key’s private path list is encrypted, too.</td>
      <td style="text-align: left">Names and selection rules stay visible in committed <code class="language-plaintext highlighter-rouge">.gitattributes</code> files.</td>
    </tr>
    <tr>
      <td style="text-align: left">Storage</td>
      <td style="text-align: left">Hooks sync readable files with separate encrypted copies under <code class="language-plaintext highlighter-rouge">.enc/</code>.</td>
      <td style="text-align: left">Git filters replace the contents stored at their original paths.</td>
    </tr>
    <tr>
      <td style="text-align: left">Plaintext mistakes</td>
      <td style="text-align: left">Readable files stay out of the index, and commits refuse private paths also tracked in clear.</td>
      <td style="text-align: left">Encryption depends on the right attributes being active before a file is added.</td>
    </tr>
    <tr>
      <td style="text-align: left">Content equality</td>
      <td style="text-align: left">Random nonces make identical contents encrypt differently.</td>
      <td style="text-align: left">Deterministic encryption reveals when two files have identical contents.</td>
    </tr>
    <tr>
      <td style="text-align: left">Integrity</td>
      <td style="text-align: left">Contents are authenticated and bound to their relative path. Swapped entries are rejected.</td>
      <td style="text-align: left">Contents are authenticated, but the ciphertext isn’t bound to its path.</td>
    </tr>
    <tr>
      <td style="text-align: left">Separate keys</td>
      <td style="text-align: left">Each key has an isolated store and encrypted file list. Other keys never touch or see it.</td>
      <td style="text-align: left">Named keys protect different sets, with the assignments visible in <code class="language-plaintext highlighter-rouge">.gitattributes</code>.</td>
    </tr>
    <tr>
      <td style="text-align: left">Private sets</td>
      <td style="text-align: left">Individual files, directory trees, and the contents of an entire branch can stay private.</td>
      <td style="text-align: left">Its own documentation recommends it for a few files, rather than most of a repository.</td>
    </tr>
  </tbody>
</table>

<p>For convenience, hooks need to run without a password prompt every time, so Git mode keeps an unlocked copy of the key in <code class="language-plaintext highlighter-rouge">.git/turbocrypt/key</code>, with restricted permissions. The password still protects the original key file.</p>

<p>I think this is cool and useful, and it can probably be useful to you, too. So, <a href="https://github.com/jedisct1/turbocrypt">give it a try</a>!</p>
 ]]></content:encoded>
    <guid isPermaLink="true">https://00f.net/2026/09/09/semi-public-git-repositories</guid>
  </item>
  
  <item>
    <title>Base84 deserves a place in file names</title>
    <link>https://00f.net/2026/09/09/base84/</link>
    <pubDate>2026-09-09T00:00:00+02:00</pubDate>
    <content:encoded><![CDATA[ <p>The <a href="https://github.com/jedisct1/turbocrypt">TurboCrypt</a> git and file encryption tool was originally designed for Unix systems.</p>

<p>And it used to encrypt file names and encode the resulting ciphertext using Base91.</p>

<p>Why Base91? Because it’s a perfect fit for encrypted file names, producing strings that can be stored as valid files on Unix and macOS.</p>

<p>“But my filesystem can store arbitrary file names”! That may be true for some filesystems, but this is without taking libraries and applications into consideration. For example, the macOS Finder would not like this at all.</p>

<p>So, Base91 worked fine for encrypted file and directory names.</p>

<p>Then people asked for Windows support, where several characters in the Unix filesystem-safe alphabet are forbidden.</p>

<p>So, TurboCrypt is switching to Base84.</p>

<p>Something surprisingly not defined nor (apparently) used anywhere, even though it’s a perfect fit for anything that should be encoded as portable filesystem-safe names.</p>

<h2 id="why-base84">Why Base84?</h2>

<p>There are 94 printable ASCII characters excluding the space. But Windows rules exclude nine of them:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt; &gt; : " / \ | ? *
</code></pre></div></div>

<p>That leaves 85.</p>

<p>But a name ending in a dot doesn’t work reliably through the Windows shell and ordinary file APIs.</p>

<p>Remove the dot as well, and we have 84 characters that can appear anywhere in a filename component. <a href="https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file">Microsoft documents these restrictions</a>.</p>

<p>Dropping dots also avoids hidden names on Unix and the special names <code class="language-plaintext highlighter-rouge">.</code> and <code class="language-plaintext highlighter-rouge">..</code>.</p>

<p>Here’s the alphabet, in encoding order:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&amp;'()+,-;=@[]^_`{}~
</code></pre></div></div>

<p>Every character is acceptable in a filename on the usual Linux, macOS and Windows filesystems. And by design, a Base84 name can never be a Windows device name.</p>

<p>These encrypted file names are designed to be generated by software and consumed by software. They are opaque names used for remote storage. Nobody is expected to type them, so all we want is filesystem compatibility and smaller expansion than Base64.</p>

<h2 id="packing-the-bits">Packing the bits</h2>

<p><a href="https://github.com/jedisct1/zig-base84">zig-base84</a> is an implementation of Base84.</p>

<p>It emits groups of five characters. Five is the sweet spot: 84<sup>5</sup> = 4,182,119,424, only 2.6% short of 2<sup>32</sup>.</p>

<p>It’s simple to implement, and that leaves enough room for a group to hold 32 bits about 95% of the time on uniformly random input, and 31 bits otherwise.</p>

<p>The encoder looks at the next 31 bits. If their value is below 84<sup>5</sup> - 2<sup>31</sup>, there’s room for a 32nd bit. Otherwise, it consumes just those 31 bits. Either way, the value fits in five base-84 digits. Pretty straightforward.</p>

<p>On random input, that’s about 31.95 bits per group, or 6.39 bits per character. The output is about 25.2% larger than the binary input. Almost Base85.</p>

<p>These expansion rates ignore the final partial group; the averages assume random input:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Encoding</th>
      <th style="text-align: right">Average expansion</th>
      <th style="text-align: right">Worst-case expansion</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">Base64</td>
      <td style="text-align: right">33.3%</td>
      <td style="text-align: right">33.3%</td>
    </tr>
    <tr>
      <td style="text-align: left">Base84</td>
      <td style="text-align: right">25.2%</td>
      <td style="text-align: right">29.0%</td>
    </tr>
  </tbody>
</table>

<p>An input filled with <code class="language-plaintext highlighter-rouge">0xff</code> forces every full group to consume only 31 bits. That’s the worst case: about 29% expansion.</p>

<p>Most filesystems cap a name at 255 bytes. Since the alphabet is ASCII, that’s 255 characters. Five divides 255 exactly, so even a maximum-length name holds only complete groups, with no bits lost to a partial one. Base84 guarantees room for 197 bytes of input, compared with 191 for unpadded Base64.</p>

<h2 id="unix-only-names">Unix-only names</h2>

<p>Unix filenames can contain most of the punctuation Windows rejects. NUL and <code class="language-plaintext highlighter-rouge">/</code> are forbidden inside a filename; the <a href="https://www.man7.org/linux/man-pages/man7/pathname.7.html">Linux pathname documentation</a> lists the rules and filesystem-specific limits.</p>

<p>The <code class="language-plaintext highlighter-rouge">filesystem</code> variant in <a href="https://github.com/jedisct1/zig-base91">zig-base91</a> replaces the standard Base91 alphabet’s slash with an apostrophe. It packs about 6.51 bits per character on random input, giving roughly 23% expansion.</p>

<p>For Unix-only names, use that variant. Standard Base91 still contains <code class="language-plaintext highlighter-rouge">/</code>, and both alphabets contain characters Windows rejects.</p>

<p>Base91 is used in <a href="https://github.com/jedisct1/hf-mount-encrypted"><code class="language-plaintext highlighter-rouge">hf-mount-encrypted</code></a>, which adds transparent encryption to the tool to mount Hugging Face buckets, because the backend runs Linux, so Windows compatibility is irrelevant.</p>

<h2 id="windows-device-names">Windows device names</h2>

<p>Windows reserves device names such as <code class="language-plaintext highlighter-rouge">CON</code>, <code class="language-plaintext highlighter-rouge">NUL</code> and <code class="language-plaintext highlighter-rouge">COM1</code>, regardless of case.</p>

<p>The five-character packing in Base84 has a useful side effect: with the standard alphabet, the encoder can’t spell a reserved device name, even for short inputs.</p>

<p>A three-character output always ends with <code class="language-plaintext highlighter-rouge">A</code> through <code class="language-plaintext highlighter-rouge">J</code>. That rules out <code class="language-plaintext highlighter-rouge">CON</code>, <code class="language-plaintext highlighter-rouge">PRN</code>, <code class="language-plaintext highlighter-rouge">AUX</code> and <code class="language-plaintext highlighter-rouge">NUL</code>, regardless of case.</p>

<p>A four-character output always ends with an uppercase letter or <code class="language-plaintext highlighter-rouge">a</code>, <code class="language-plaintext highlighter-rouge">b</code>, <code class="language-plaintext highlighter-rouge">c</code>. It can’t end with a digit, so <code class="language-plaintext highlighter-rouge">COM1</code> through <code class="language-plaintext highlighter-rouge">COM9</code> and <code class="language-plaintext highlighter-rouge">LPT1</code> through <code class="language-plaintext highlighter-rouge">LPT9</code> are impossible too. The superscript digits Windows also reserves aren’t in the alphabet.</p>

<p>And the alphabet has no dots, so a reserved name followed by an extension is also impossible.</p>

<p>Unlike other schemes such as Base64, no padding or special handling is needed to avoid these names.</p>

<h2 id="how-about-case-insensitive-filesystems">How about case-insensitive filesystems?</h2>

<p>Base84 distinguishes uppercase and lowercase letters, so different encoded names can compare equal on a <a href="https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions">case-insensitive filesystem</a>.</p>

<p>For encrypted names, the collision probability depends on how much pseudorandom data gets encoded. TurboCrypt pads short names to 16 bytes before applying HCTR2. Modeling that encryption as a random permutation gives us 128-bit ciphertexts even for the shortest names.</p>

<p>Those 16 bytes encode to 20 or 21 characters. Even pretending every character is a letter, there are at most 2<sup>21</sup> spellings that differ only in case.</p>

<p>Each valid spelling represents at most one of the 2<sup>128</sup> possible inputs, so a conservative upper bound on a case collision between two distinct encrypted names is 2<sup>21</sup> / 2<sup>128</sup> = 2<sup>-107</sup>. And longer ciphertexts give tighter bounds.</p>

<p>For <code class="language-plaintext highlighter-rouge">n</code> distinct names in the same directory, the birthday bound gives a collision probability of at most n(n - 1) / 2<sup>108</sup>. Even with a billion names, that’s below 3.1 × 10<sup>-15</sup>. Negligible.</p>
 ]]></content:encoded>
    <guid isPermaLink="true">https://00f.net/2026/09/09/base84</guid>
  </item>
  
  <item>
    <title>Why compiling Rust to WebAssembly is slow</title>
    <link>https://00f.net/2026/08/19/why-compiling-rust-to-webassembly-is-slow/</link>
    <pubDate>2026-08-19T00:00:00+02:00</pubDate>
    <content:encoded><![CDATA[ <p>Compiling Rust to WebAssembly with debug info is slower than it should be. Sometimes unbearably slower.</p>

<p>For example, here’s a <a href="https://github.com/dip-proto/rust-wasm-debug-superslow-compile-time">40-line Rust reproducer</a> that takes 50 seconds to build with debug info and 1.5 seconds without it.</p>

<p>This reproducer was reduced from a crate (<code class="language-plaintext highlighter-rouge">ed25519-compact</code>) where enabling debug info made compilation ~40x slower, but the bug itself is broader and affects all Rust code compiled to WebAssembly to varying degrees.</p>

<p>This is actually a known LLVM bug that had already been reported and fixed for <code class="language-plaintext highlighter-rouge">clang</code>. But the fix is incomplete.</p>

<h2 id="debug-info-becomes-records-in-the-instruction-list">Debug info becomes records in the instruction list</h2>

<p>Cargo has a <code class="language-plaintext highlighter-rouge">debug</code> setting to control debug info. <code class="language-plaintext highlighter-rouge">debug = 2</code> asks LLVM for full DWARF information, which very few people use in practice with WebAssembly, but which people like to enable anyway (if only because <code class="language-plaintext highlighter-rouge">debug = true</code> is an alias for <code class="language-plaintext highlighter-rouge">debug = 2</code>).</p>

<p>This debugging data is designed for profiling a wasm binary and getting real symbol names in stack traces. It’s also the default for Rust’s <code class="language-plaintext highlighter-rouge">dev</code> profile.</p>

<p>Something important to understand first: LLVM represents a source variable’s location with a <code class="language-plaintext highlighter-rouge">DBG_VALUE</code> record.</p>

<p>The record says that, at this point in the generated code, a variable lives in a register, a stack slot, or a constant. It sits in LLVM’s machine-level intermediate representation, or MIR, and produces no code by itself.</p>

<p>But it’s relevant when code is moved. A debugger must see the right value, so every pass that moves an instruction has to move or update its <code class="language-plaintext highlighter-rouge">DBG_VALUE</code> records too.</p>

<p>With <code class="language-plaintext highlighter-rouge">debug = 2</code>, heavy inlining can produce hundreds of thousands of <code class="language-plaintext highlighter-rouge">DBG_VALUE</code> records in one function.</p>

<p>And when targeting WebAssembly, a lot of code has to be moved.</p>

<h2 id="webassembly-has-to-move-values-onto-the-stack">WebAssembly has to move values onto the stack</h2>

<p>Unlike native targets, WebAssembly is a stack machine.</p>

<p>LLVM first generates instructions using named temporary registers, then a backend pass called “Register Stackify” moves values it can use onto the stack near the end of code generation.</p>

<p>A definition computes a value, while a use consumes it.</p>

<p>And when a definition has one use, Register Stackify can move that definition immediately before the use.</p>

<p>The value then stays on the wasm operand stack instead of passing through a local, which saves wasm code and runtime work.</p>

<p>This happens inside a basic block, a straight sequence of instructions with no branches into or out of its middle.</p>

<p>But as we saw before, moving a definition also means moving its debug records. This is where things start to suck.</p>

<h2 id="the-pass-keeps-rescanning-the-list-it-grows">The pass keeps rescanning the list it grows</h2>

<p>Before it can move a definition, the pass scans from that definition to the end of its basic block for its debug records, stopping if the register is defined again.</p>

<p>It also scans from the definition to the place where the instruction will be inserted, collecting records for the variables it tracks.</p>

<p>Those are linear scans.</p>

<p>But repeating them for many definitions turns them into quadratic work: twice as many records can mean four times as much scanning. Yikes.</p>

<p>The pass also makes its own input larger as it runs.</p>

<p>When it sinks a definition, it leaves the old debug records in the instruction list with their locations blanked out instead of deleting them.</p>

<p>When a value is cheap to compute, such as a constant, it computes that value again at every use instead of carrying it around. Each copy gets fresh <code class="language-plaintext highlighter-rouge">DBG_VALUE</code> records. Re-yikes.</p>

<p>So the pass keeps adding records to the same list it keeps rescanning. It’s very inefficient and awful for large functions.</p>

<h2 id="a-small-reproducer">A small reproducer</h2>

<p>That <a href="https://github.com/dip-proto/rust-wasm-debug-superslow-compile-time">reproducer</a> repeatedly squares a <code class="language-plaintext highlighter-rouge">[u64; 5]</code> through a chain of <code class="language-plaintext highlighter-rouge">#[inline(always)]</code> functions.</p>

<p>On my machine, this command:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cargo build <span class="nt">--release</span> <span class="nt">--target</span><span class="o">=</span>wasm32-unknown-unknown
</code></pre></div></div>

<p>produced:</p>

<table>
  <thead>
    <tr>
      <th>Configuration</th>
      <th style="text-align: right">Build time</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">debug = 2</code></td>
      <td style="text-align: right">50.56s</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">debug = 0</code></td>
      <td style="text-align: right">1.55s</td>
    </tr>
  </tbody>
</table>

<p>The 1.55 seconds is the whole <code class="language-plaintext highlighter-rouge">cargo build</code> time with debug info off.
Adding full debug info turns the same build into a 50-second wait. Ouch!</p>

<p>And <code class="language-plaintext highlighter-rouge">rustc -Z time-llvm-passes</code> shows where it goes.</p>

<p>With <code class="language-plaintext highlighter-rouge">debug = 2</code>, LLVM pass time was 50.85s: WebAssembly Register Stackify took 43.51s, or 85.6%, and Explicit Locals took 6.31s, or 12.4%. Everything else is negligible.</p>

<p>With <code class="language-plaintext highlighter-rouge">debug = 0</code>, total pass time was 1.42s. Register Stackify took 0.96s and Explicit Locals took 0.003s, making them roughly 45x and 2000x slower with debug info.</p>

<p>This is all due to the inefficient handling of <code class="language-plaintext highlighter-rouge">DBG_VALUE</code> records.</p>

<p>The crate looks small and innocent: it just produces one function with one basic block.</p>

<p>But by the time Register Stackify is done, it has about 90k real instructions, 267k <code class="language-plaintext highlighter-rouge">DBG_VALUE</code> records, and 355k lines of MIR. Explicit Locals isn’t broken. It’s a linear pass that receives 350k instructions instead of 90k. Pretty bad.</p>

<h2 id="llvms-fix-is-incomplete">LLVM’s fix is incomplete</h2>

<p>There’s already <a href="https://github.com/llvm/llvm-project/issues/168326">llvm/llvm-project issue #168326</a>, which was reported against <code class="language-plaintext highlighter-rouge">clang</code> and describes the same problem.</p>

<p>It was closed on 2026-03-27 by commit <a href="https://github.com/llvm/llvm-project/commit/fe990b9005260bcf4a5630b577483e954c6bb60e">fe990b9005260bcf4a5630b577483e954c6bb60e</a>.</p>

<p>The way it works is that it counts a register’s <code class="language-plaintext highlighter-rouge">DBG_VALUE</code> uses, then stops the forward scan after it has found them all. The counter is provided by a use list, the compiler’s unordered list of every place a value is used.</p>

<p>But that doesn’t help Rust (TBH it does, but very little).</p>

<p>The catch is that, while moving values, Register Stackify can point an existing <code class="language-plaintext highlighter-rouge">DBG_VALUE</code> at a different register without moving the record itself.</p>

<p>And after enough copies, a record near the top of the block can refer to a register defined near the bottom.</p>

<p>It appears in that register’s use list, yet a forward scan from the definition can never reach it. The counter includes the earlier record, so it never reaches zero.</p>

<h2 id="a-better-fix">A better fix</h2>

<p>Here’s <a href="https://github.com/dip-proto/rust-wasm-debug-superslow-compile-time/blob/master/llvm-wasm-debug-fix.patch">a better fix as a single patch</a> that can be applied to the LLVM code that currently ships with Rust.</p>

<p>It contains three independent changes.</p>

<p>The first change is in the WebAssembly debug-record helper.</p>

<p>It stops Register Stackify from reading to the end of a block when it doesn’t need to.</p>

<p>The compiler already keeps a list of every place a value is used, including the debug records that mention it, so it knows how many records it needs to find. The old code still read forward from the definition until it reached the end of the block. After copies, some records can sit above the definition.</p>

<p>A forward scan can never reach them, so its count never reaches zero and it always reads to the end. That’s why LLVM’s existing change doesn’t help this Rust case.</p>

<p>The patch makes the thing walk upward as well as forward. The upward walk counts off records above the definition, while the forward walk keeps the same order and still stops at another definition.</p>

<p>Once both walks have accounted for every record, they stop. The compiler finds the same records in the same order without reading the rest of the block.</p>

<p>That same first change also avoids building expensive lookup keys for records it will discard. While collecting records between two points, the old code built and hashed a full identifier for every record it passed, then usually threw it away because it described a variable it didn’t track.</p>

<p>Now it first asks whether it cares about that variable with one cheap comparison. That reduced hash-table lookups from 27.4M to 3.0M. Pretty significant.</p>

<p>The second change is a non-WebAssembly-specific change.</p>

<p>LLVM gives real instructions position numbers so it can compare their order without walking the instruction list. But debug records never get a position number, yet the old code looked each one up before learning that it wasn’t there. The patch just skips those useless lookups. And every target benefits from it, not just WebAssembly.</p>

<p>The third change is back in the WebAssembly pass.</p>

<p>It asks a cheaper question about whether one instruction always runs before another. The general-purpose helper answered by walking the block from the beginning every time. The instructions already have position numbers, so comparing two numbers gives the same answer.</p>

<p>These changes don’t affect the compiled code itself at all, so there are no runtime performance regressions or behavior changes.</p>

<h2 id="lets-benchmark-the-reproducer-again">Let’s benchmark the reproducer again</h2>

<p>Here are <code class="language-plaintext highlighter-rouge">llc -O3 -time-passes</code> measurements on the reproducer’s bitcode, the compiled form of the program that LLVM reads:</p>

<table>
  <thead>
    <tr>
      <th>Pass</th>
      <th style="text-align: right">Before</th>
      <th style="text-align: right">After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>WebAssembly Register Stackify</td>
      <td style="text-align: right">45.9s</td>
      <td style="text-align: right">1.19s</td>
    </tr>
    <tr>
      <td>WebAssembly Explicit Locals</td>
      <td style="text-align: right">6.5s</td>
      <td style="text-align: right">0.013s</td>
    </tr>
    <tr>
      <td>Total codegen pass time</td>
      <td style="text-align: right">53.3s</td>
      <td style="text-align: right">2.17s</td>
    </tr>
  </tbody>
</table>

<p>With debug info disabled, Register Stackify takes 1.14s on this input. The remaining debug-info overhead in that pass is about 0.05 seconds.</p>

<p>End to end, <code class="language-plaintext highlighter-rouge">debug = 2</code> fell from 50.56s to 2.72s.</p>

<p>And <code class="language-plaintext highlighter-rouge">debug = 0</code> fell from 1.55s to 0.89s. Pretty cool.</p>

<p>Processing and emitting 267k debug records still costs time, but the quadratic blowup is gone.</p>

<h2 id="testing-on-real-world-code">Testing on real-world code</h2>

<p>Does this affect code people actually compile? Yes.</p>

<p>I repeated the crate benchmark with common crates.</p>

<p>Every crate used the release profile with <code class="language-plaintext highlighter-rouge">debug = 2</code> and <code class="language-plaintext highlighter-rouge">codegen-units = 1</code>.</p>

<p>I emitted LLVM bitcode for every crate with:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">RUSTFLAGS</span><span class="o">=</span><span class="s2">"--emit=llvm-bc"</span> cargo build <span class="nt">--release</span> <span class="nt">--target</span><span class="o">=</span>wasm32-unknown-unknown
</code></pre></div></div>

<p>Then I ran each module through both compilers:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>llc <span class="nt">-O3</span> <span class="nt">-time-passes</span> <span class="nt">-filetype</span><span class="o">=</span>obj <span class="nt">-o</span> /dev/null &lt;crate&gt;.bc
</code></pre></div></div>

<p>Both <code class="language-plaintext highlighter-rouge">llc</code> binaries came from the same source tree with the same build configuration. The patch was the only difference.</p>

<h3 id="cryptography">Cryptography</h3>

<p>I benchmarked an app with a bunch of crypto crates: <code class="language-plaintext highlighter-rouge">aegis</code>, <code class="language-plaintext highlighter-rouge">sha2</code>, <code class="language-plaintext highlighter-rouge">sha3</code>, <code class="language-plaintext highlighter-rouge">blake2</code>, <code class="language-plaintext highlighter-rouge">blake3</code>, <code class="language-plaintext highlighter-rouge">md-5</code>, <code class="language-plaintext highlighter-rouge">ripemd</code>, <code class="language-plaintext highlighter-rouge">chacha20poly1305</code>, <code class="language-plaintext highlighter-rouge">aes-gcm</code>, <code class="language-plaintext highlighter-rouge">argon2</code>, <code class="language-plaintext highlighter-rouge">curve25519-dalek</code>, <code class="language-plaintext highlighter-rouge">ed25519-compact</code>, <code class="language-plaintext highlighter-rouge">k256</code>, <code class="language-plaintext highlighter-rouge">p256</code>, <code class="language-plaintext highlighter-rouge">p384</code>, <code class="language-plaintext highlighter-rouge">ahash</code>, etc.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th style="text-align: right">Before</th>
      <th style="text-align: right">After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Time in Register Stackify, all 134 modules</td>
      <td style="text-align: right">1702.7s</td>
      <td style="text-align: right">26.2s</td>
    </tr>
    <tr>
      <td>Total wasm code generation time, all 134 modules</td>
      <td style="text-align: right">1922.2s</td>
      <td style="text-align: right">67.4s</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">ed25519-compact</code> 2.4.0 alone went from 1683.4s to 23.2s in Register Stackify, and from 1884.9s to 48.2s for total code generation.</p>

<p>That’s 31 minutes of code generation for one ordinary crate, down to 48 seconds.</p>

<p>The patched compiler still spends 23 seconds in that pass. Once its field arithmetic is inlined, the crate really is enormous. The quadratic scan is what turned 23 seconds into half an hour.</p>

<p>Here’s a detailed benchmark for some crates:</p>

<table>
  <thead>
    <tr>
      <th>Crate</th>
      <th style="text-align: right">Register Stackify before</th>
      <th style="text-align: right">after</th>
      <th style="text-align: right">Whole code generation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ed25519-compact</code></td>
      <td style="text-align: right">1683.4s</td>
      <td style="text-align: right">23.2s</td>
      <td style="text-align: right">39x faster</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">k256</code></td>
      <td style="text-align: right">5.06s</td>
      <td style="text-align: right">0.21s</td>
      <td style="text-align: right">4.8x faster</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">blake2</code></td>
      <td style="text-align: right">1.43s</td>
      <td style="text-align: right">0.21s</td>
      <td style="text-align: right">4.9x faster</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ripemd</code></td>
      <td style="text-align: right">0.61s</td>
      <td style="text-align: right">0.064s</td>
      <td style="text-align: right">3.9x faster</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sha2</code></td>
      <td style="text-align: right">3.01s</td>
      <td style="text-align: right">0.83s</td>
      <td style="text-align: right">2.8x faster</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">p256</code></td>
      <td style="text-align: right">3.22s</td>
      <td style="text-align: right">0.44s</td>
      <td style="text-align: right">2.4x faster</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">p384</code></td>
      <td style="text-align: right">0.89s</td>
      <td style="text-align: right">0.12s</td>
      <td style="text-align: right">2.3x faster</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">blake3</code></td>
      <td style="text-align: right">0.27s</td>
      <td style="text-align: right">0.046s</td>
      <td style="text-align: right">2.3x faster</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">jwt-simple</code></td>
      <td style="text-align: right">0.68s</td>
      <td style="text-align: right">0.073s</td>
      <td style="text-align: right">1.6x faster</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">curve25519-dalek</code></td>
      <td style="text-align: right">0.14s</td>
      <td style="text-align: right">0.021s</td>
      <td style="text-align: right">1.3x faster</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">aegis</code></td>
      <td style="text-align: right">0.017s</td>
      <td style="text-align: right">0.0028s</td>
      <td style="text-align: right">1.2x faster</td>
    </tr>
  </tbody>
</table>

<p>For <code class="language-plaintext highlighter-rouge">sha2</code>, 88% of the crate’s entire WebAssembly code-generation time was in Register Stackify before the fix.</p>

<h3 id="non-crypto-things">Non-crypto things</h3>

<p>I also tried a bunch of compression crates (<code class="language-plaintext highlighter-rouge">brotli</code>, <code class="language-plaintext highlighter-rouge">flate2</code>, <code class="language-plaintext highlighter-rouge">miniz_oxide</code>, <code class="language-plaintext highlighter-rouge">ruzstd</code>, <code class="language-plaintext highlighter-rouge">zopfli</code>, <code class="language-plaintext highlighter-rouge">libflate</code>, <code class="language-plaintext highlighter-rouge">lzma-rs</code>, <code class="language-plaintext highlighter-rouge">snap</code>, <code class="language-plaintext highlighter-rouge">bzip2-rs</code>, <code class="language-plaintext highlighter-rouge">zune-inflate</code>, <code class="language-plaintext highlighter-rouge">lz4_flex</code>).</p>

<p>Register Stackify went from 0.51s to 0.19s, 2.6x faster.</p>

<p>I also tried linear algebra crates (<code class="language-plaintext highlighter-rouge">nalgebra</code>, <code class="language-plaintext highlighter-rouge">glam</code>, and <code class="language-plaintext highlighter-rouge">cgmath</code>), and compilation got 4.3x faster.</p>

<p>The bug shows up everywhere, but it really gets expensive when a function gets big.</p>

<p>The target (<code class="language-plaintext highlighter-rouge">wasm32-unknown-unknown</code>, <code class="language-plaintext highlighter-rouge">wasm32-wasip1</code>, etc.) also doesn’t make any difference.</p>

<h2 id="who-pays-for-it-today">Who pays for it today</h2>

<p>Rust users targeting any wasm target with debug info enabled are affected, including the default <code class="language-plaintext highlighter-rouge">dev</code> profile and release profiles with <code class="language-plaintext highlighter-rouge">debug = 2</code> (<code class="language-plaintext highlighter-rouge">debug = 1</code> isn’t affected).</p>

<p>C and C++ users compiling wasm with clang and <code class="language-plaintext highlighter-rouge">-g</code> are affected too, which is how issue #168326 in LLVM first appeared.</p>

<p>More codegen units or no debug info work around the problem until the patch lands, but that’s not ideal.</p>

<p>Is it going to be fixed once Rust updates its LLVM fork to include the fix originally made for <code class="language-plaintext highlighter-rouge">clang</code>?</p>

<p>Let’s see:</p>

<table>
  <thead>
    <tr>
      <th>Module</th>
      <th>Current Rust</th>
      <th>Upstream LLVM fix</th>
      <th>Ours</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>repro RegStackify</td>
      <td>45.37s</td>
      <td>6.32s</td>
      <td>1.17s</td>
    </tr>
    <tr>
      <td>repro total codegen</td>
      <td>52.58s</td>
      <td>7.12s</td>
      <td>1.99s</td>
    </tr>
    <tr>
      <td>sha2 RegStackify</td>
      <td>3.04s</td>
      <td>2.35s</td>
      <td>0.83s</td>
    </tr>
    <tr>
      <td>sha2 total codegen</td>
      <td>3.46s</td>
      <td>2.75s</td>
      <td>1.21s</td>
    </tr>
    <tr>
      <td>k256 RegStackify</td>
      <td>5.07s</td>
      <td>0.67s</td>
      <td>0.22s</td>
    </tr>
    <tr>
      <td>k256 total codegen</td>
      <td>6.79s</td>
      <td>1.86s</td>
      <td>1.42s</td>
    </tr>
  </tbody>
</table>

<p>Well… no. An LLVM update will definitely help, but not as much as the changes proposed here.</p>
 ]]></content:encoded>
    <guid isPermaLink="true">https://00f.net/2026/08/19/why-compiling-rust-to-webassembly-is-slow</guid>
  </item>
  
  <item>
    <title>An improved attack on 7-round AES</title>
    <link>https://00f.net/2026/07/30/an-improved-attack-on-7-round-aes/</link>
    <pubDate>2026-07-30T00:00:00+02:00</pubDate>
    <content:encoded><![CDATA[ <p>Recently, Milad Nasr and Nicholas Carlini used an AI model to <a href="https://anthropic.com/document/aes_mobius_bridge.pdf">improve an earlier attack on seven-round AES-128</a>.</p>

<p>And <a href="https://qiita.com/satokan3">Satoru Kanno (@satokan3)</a> wrote <a href="https://qiita.com/satokan3/items/d8530ccb4a93f4ac76d1">a great explanation of the result in Japanese</a>.</p>

<p>Here’s a walkthrough based on his blog post and my understanding of the results, in English.</p>

<p>TLDR: Don’t panic. It’s an incremental improvement to an impractical reduced-round attack. Full AES-128 remains unaffected.</p>

<p>But what’s interesting is how the researchers used an AI model to find the improvement.</p>

<h2 id="what-the-result-actually-covers">What the result actually covers</h2>

<p>Standard AES-128 has ten rounds.</p>

<p>But this attack stops after seven. So, this is not the real cipher. But studying reduced-round ciphers is a common practice in order to better understand the security margin of a primitive.</p>

<p>A better attack on seven rounds is useful research, but it says nothing alarming about the complete cipher.</p>

<p>The attack also needs about <code class="language-plaintext highlighter-rouge">2^105</code> carefully arranged plaintext blocks, all encrypted under the same unknown key.
This is way outside the recommended (and pratical) limits of AES usage, and just streaming that many 16-byte blocks at 1 TB/s would take roughly 20 trillion years.</p>

<p>And an attacker would have to collect them from real network traffic. Plus, they’d need access to an encryption oracle and the ability to choose a vast number of specially structured inputs.</p>

<p>That’s a lot to ask! So to be clear: this scenario is completely unrealistic. There’s no practical attack here. AES remains fine.</p>

<h2 id="how-the-research-happened">How the research happened</h2>

<p>Nasr and Carlini have long records in security research. In particular, they recently coauthored <a href="https://www.cybergym.io/exploitgym/">ExploitGym</a>, which tests whether AI agents can turn real software vulnerabilities into working exploits.</p>

<p>For this project, they used a custom research scaffold, sponsored by Anthropic (AI usage cost alone for that task was evaluated to about $100,000)</p>

<p>It gave the model access to papers, code, and experiments, and let long searches continue between human prompts.</p>

<p>And the early runs concluded that AES had already been studied too thoroughly to leave an easy improvement.</p>

<p>So, the team kept the search focused: they changed the instructions, rejected easier targets, and pushed the model to work on a publishable attack against seven rounds.</p>

<p>Their scaffold even let the model modify parts of its own agent loop.</p>

<p>And after three days, the output from one run contained the idea that became the Möbius Bridge.</p>

<p>Finally, a few more days of experiments refined it into the attack described in the paper.</p>

<p>The paper says the model produced the bridge, the optimization techniques, and the correctness arguments. Pretty cool.</p>

<p>It’s important to note that the AI model did substantial work inside the system the researchers built, but it didn’t independently decide to study AES or publish a result.</p>

<p>Nasr and Carlini spent several hundred hours, nearly a month, checking the claims and writing the paper.</p>

<p>They’re the researchers responsible for the work. The Claude Mythos model was a tool they used.</p>

<h2 id="the-expensive-byte-guess">The expensive byte guess</h2>

<p>The new attack builds on a <a href="https://doi.org/10.1007/978-3-642-38348-9_23">2013 meet-in-the-middle attack</a>.</p>

<p>A meet-in-the-middle attack computes forward from the plaintext and backward from the ciphertext, then looks for a match in the middle. And in this case, a large precomputed table describes a four-round core of AES.</p>

<p>To reach that core, the older attack had to guess selected bytes from the outer rounds.</p>

<p>And one of those bytes had 256 possible values. For every candidate at this stage, the attack tried all 256 values and rebuilt the data for a table lookup each time.</p>

<h2 id="the-möbius-bridge">The Möbius Bridge</h2>

<p>Now, here’s the cool part, the shortcut. The AES S-box made it possible.</p>

<p>Because the S-box isn’t an arbitrary lookup table: it first inverts each byte in a 256-element finite field, then applies a fixed affine transformation.</p>

<p>And at this stage, the two sides of the attack see different versions of the same collection.</p>

<p>If an offline difference is called <code class="language-plaintext highlighter-rouge">d</code>, the paper’s bridge identity is <code class="language-plaintext highlighter-rouge">g = s^2 * d^-1 XOR s</code>, where <code class="language-plaintext highlighter-rouge">g</code> is the corresponding online value and <code class="language-plaintext highlighter-rouge">s</code> is an unknown byte shared by the entire collection.</p>

<p>As a function of <code class="language-plaintext highlighter-rouge">d</code>, this is a particular Möbius transformation.</p>

<p>The researchers then take the reciprocals of the offline differences. If <code class="language-plaintext highlighter-rouge">v = d^-1</code>, the relationship becomes the affine map <code class="language-plaintext highlighter-rouge">v -&gt; s^2 * v XOR s</code>.</p>

<p>The Möbius Bridge replaces the collection with a fingerprint that stays identical under that shared transformation.
The individual values change, but the fingerprint doesn’t.</p>

<p>The attack can therefore check the precomputed table without trying all 256 values of the key byte first.</p>

<p><img src="/img/posts/aes-mobius-bridge.svg" alt="The previous attack tries all 256 values of one key byte before comparing with the offline table. The Möbius Bridge rewrites a shared Möbius relation as an affine map on reciprocal values, then replaces that loop with an invariant fingerprint and one comparison." /></p>

<p>The formula in the diagram describes the relation between two collections when the surrounding key guesses are correct.
It doesn’t describe complete AES states.</p>

<p>The bridge doesn’t remove the byte from the final key: it removes the 256-way guess from this table-lookup stage, and a later step recovers the byte.</p>

<p>Other key guesses remain, and bytes that pass through <code class="language-plaintext highlighter-rouge">MixColumns</code> don’t preserve the same convenient relation.</p>

<p>Removing the loop suggests a 256-fold speedup. But it’s important to note that computing the fingerprint is expensive, so some of that gain disappears.</p>

<p>After several optimizations, the estimated speedup is about 200 to 800 times. Pretty impressive, but once again, that has no practical impact.</p>

<h2 id="the-data-still-dominates">The data still dominates</h2>

<p>For the same <code class="language-plaintext highlighter-rouge">2^105</code> chosen plaintexts and a roughly 63 percent chance of success, the estimates are:</p>

<table>
  <thead>
    <tr>
      <th>Attack</th>
      <th style="text-align: right">Chosen plaintext blocks</th>
      <th style="text-align: right">Estimated AES-equivalent work</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Previous attack</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">2^105</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">2^99</code></td>
    </tr>
    <tr>
      <td>Möbius Bridge</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">2^105</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">2^89.3</code> to <code class="language-plaintext highlighter-rouge">2^91.4</code></td>
    </tr>
  </tbody>
</table>

<p>That’s a large reduction but the impossible <code class="language-plaintext highlighter-rouge">2^105</code> data requirement hasn’t changed, so data remains the dominant cost.</p>

<h2 id="what-has-actually-been-tested">What has actually been tested</h2>

<p>Obviously, the complete attack has never been run because it’s far too expensive. So, the researchers tested its components instead. See the <a href="https://github.com/anthropics/cryptography-research-demo/tree/main/AES">code they released</a>.</p>

<p>The researchers also ran an end-to-end attack on a toy cipher based on AES with a 24-bit key.</p>

<p>For full-size seven-round AES-128, they recovered 50 out of 50 planted keys, but it skipped the two parts that can’t be run at full scale.</p>

<p>It used the one table entry that should match and to identify the rare plaintext pair that the complete attack would have to find.</p>

<p>A Lean theorem checks the core bridge identity, while large computational experiments test the fingerprint’s behavior.
Cool, but neither proves the complete attack.</p>

<p>We should emphasize that Anthropic published the work as a technical report rather than a peer-reviewed paper.
Cryptographers outside Anthropic commented on an early draft, but broad independent review has only just begun.
The full attack is too expensive to validate on actual hardware.</p>

<p>That being said, the work remains a clever incremental result in reduced-round AES cryptanalysis.</p>

<p>These results show that a capable AI model can be a useful tool when experienced people choose the problem, give it room to explore, and check every important claim.</p>
 ]]></content:encoded>
    <guid isPermaLink="true">https://00f.net/2026/07/30/an-improved-attack-on-7-round-aes</guid>
  </item>
  
  <item>
    <title>AES gets swizzled</title>
    <link>https://00f.net/2026/07/16/aes-with-simd-swizzles/</link>
    <pubDate>2026-07-16T00:00:00+02:00</pubDate>
    <content:encoded><![CDATA[ <p>There’s an old problem with AES when implemented in software: it’s either slow or insecure.</p>

<p>AES has a state of sixteen bytes, and a round has four steps:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">SubBytes</code> replaces every byte using the AES S-Box.</li>
  <li><code class="language-plaintext highlighter-rouge">ShiftRows</code> moves bytes to different columns.</li>
  <li><code class="language-plaintext highlighter-rouge">MixColumns</code> combines the four bytes in each column.</li>
  <li><code class="language-plaintext highlighter-rouge">AddRoundKey</code> XORs another sixteen-byte value.</li>
</ol>

<p>And <code class="language-plaintext highlighter-rouge">SubBytes</code> is the annoying part, because it applies a random-looking permutation to every byte:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SBox(0x00) = 0x63
SBox(0x53) = 0xed
...
</code></pre></div></div>

<p>An obvious way to implement that is by using lookup tables.</p>

<p>But here’s the problem: the lookup indices are secret data, and accessing a cache line that was just accessed and is still in the cache is slightly faster than accessing other addresses.</p>

<p>Taking advantage of this, an adversary on the same machine can learn information about the secret indices. For example, DJB <a href="https://cr.yp.to/antiforgery/cachetiming-20050414.pdf">recovered AES keys from remote timings</a>, and Osvik, Shamir, and Tromer demonstrated <a href="https://eprint.iacr.org/2005/271">cross-process attacks against OpenSSL and disk encryption</a>.</p>

<p>That was mostly solved on modern mobile, desktop, and server CPUs by adding AES instructions.</p>

<p>But old is new again. With new platforms such as WebAssembly, even when running on such CPUs, applications can’t use AES instructions, so they have to reimplement AES themselves. Sigh.</p>

<p>A common way to avoid using lookup tables is bitslicing: using a circuit of logical operations applied to a different representation of the AES state, where all the bits expected to follow the same circuit are packed together in a register.</p>

<p>It works very well in hardware, but in software, performance is generally still not great, especially compared to what CPU AES instructions can do.</p>

<p>But there’s a third option that’s surprisingly not well known and was originally described by Mike Hamburg in <a href="https://www.shiftleft.org/papers/vector_aes/vector_aes.pdf">Accelerating AES with Vector Permute Instructions</a>.</p>

<h2 id="a-lookup-table-inside-a-register">A lookup table inside a register</h2>

<p>Modern CPUs, even when accessed via WebAssembly, include something nice: SIMD registers that contain 16 bytes or more.</p>

<p>A lot of instructions can be used with such registers, but a very common one, which (oh, joy!) is even accessible in WebAssembly, treats one 16-byte vector as a table and another as sixteen selectors:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>table     = [t0, t1, t2, ... t15]
selectors = [ 3,  9,  0, ...   7]
result    = [t3, t9, t0, ...  t7]
</code></pre></div></div>

<p>A single instruction effectively performs sixteen lookups in parallel. The table is kept in a register, so there are no memory lookups, and (barring microarchitectural vulnerabilities) no side channels.</p>

<p><img src="/img/posts/simd-aes-lookups.svg" alt="A secret byte selects an AES S-Box address in memory, while a SIMD byte permutation selects lanes in a register loaded from a fixed address." /></p>

<p>This is something that all CPUs with SIMD instructions support. On x86, it’s called <code class="language-plaintext highlighter-rouge">PSHUFB</code>, on ARM, <code class="language-plaintext highlighter-rouge">TBL</code>, and WebAssembly has a corresponding instruction called <code class="language-plaintext highlighter-rouge">i8x16.swizzle</code>, which any sane compiler can map directly to <code class="language-plaintext highlighter-rouge">PSHUFB</code> or <code class="language-plaintext highlighter-rouge">TBL</code>.</p>

<p>When selectors are between 0 and 15, the behavior is as expected, and the same across all targets.</p>

<p>Values outside that range are target-specific, which is important for WebAssembly (more about that later).</p>

<h2 id="turning-256-values-into-16-by-16">Turning 256 values into 16 by 16</h2>

<p>The AES S-Box isn’t actually random. It’s defined as:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>S(b) = A(inverse(b)) XOR 0x63
</code></pre></div></div>

<p>The byte is inverted in the AES finite field. Then a fixed linear bit transformation <code class="language-plaintext highlighter-rouge">A</code> and the constant <code class="language-plaintext highlighter-rouge">0x63</code> are applied. And the inverse of zero is defined as zero.</p>

<p>AES uses a finite field with 256 elements, commonly written <code class="language-plaintext highlighter-rouge">GF(2^8)</code>. And the same field can be represented as a quadratic extension of a 16-element field:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GF(2^8) is isomorphic to GF(2^4)[t] / (t^2 + t + zeta)
</code></pre></div></div>

<p>With these binary encodings, field addition in both <code class="language-plaintext highlighter-rouge">GF(2^8)</code> and <code class="language-plaintext highlighter-rouge">GF(2^4)</code> is bitwise XOR.</p>

<p>Here <code class="language-plaintext highlighter-rouge">zeta</code> is chosen so that the quadratic polynomial is irreducible.</p>

<p>And here’s something interesting:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>256 = 16 * 16
</code></pre></div></div>

<p>This is just a reversible change of coordinates.</p>

<p>And in the new representation, one byte becomes a pair of encoded four-bit field components, while addition and multiplication keep working as expected.</p>

<p>A linear input transformation produces those components:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lo = input &amp; 0x0f
hi = input &gt;&gt; 4

encoded = lookup16(input_map_lo, lo) XOR lookup16(input_map_hi, hi)
</code></pre></div></div>

<p>See? The low and high nibble contributions can be looked up separately because the transform is linear.</p>

<p>With two permutations, we can transform all sixteen AES state bytes.</p>

<h2 id="inversion-with-small-register-tables">Inversion with small register tables</h2>

<p>Hamburg’s nested-inversion construction reduces the nonlinear part of the field inverse to five small lookups:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k = low_nibble(encoded)
i = high_nibble(encoded)
j = k XOR i

ak  = lookup16(inverse_scaled, k)
iak = lookup16(inverse, i) XOR ak
jak = lookup16(inverse, j) XOR ak
io  = lookup16(inverse, iak) XOR j
jo  = lookup16(inverse, jak) XOR i
</code></pre></div></div>

<p>The stages use scaled and skewed representations, so treating every value as an ordinary nibble in one fixed <code class="language-plaintext highlighter-rouge">GF(2^4)</code> basis gives incorrect results.</p>

<p>So, two output tables apply the remaining factors, return to the normal AES byte basis, and apply the linear part of the S-Box affine map:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>a = lookup16(output_u, io) XOR lookup16(output_t, jo)

a XOR 0x63 = AES_SBOX[input]
</code></pre></div></div>

<p>With the target-specific infinity handling described below, we get the correct S-Box value for every possible input byte. And we only need 16-element lookup tables. Each fits in a SIMD register. You see where this is going.</p>

<h2 id="folding-the-rest-of-the-round">Folding the rest of the round</h2>

<p><code class="language-plaintext highlighter-rouge">MixColumns</code> is evaluated on the constant-free S-Box value <code class="language-plaintext highlighter-rouge">a = A(inverse(input))</code>.</p>

<p>It needs both <code class="language-plaintext highlighter-rouge">a</code> and <code class="language-plaintext highlighter-rouge">a</code> multiplied by <code class="language-plaintext highlighter-rouge">0x02</code> in the AES field (<code class="language-plaintext highlighter-rouge">3a</code> is also needed, but that’s just <code class="language-plaintext highlighter-rouge">a + 2a</code>).</p>

<p>Since that operation is linear, a second pair of output tables can produce the doubled value directly:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>a2 = lookup16(output2_u, io) XOR lookup16(output2_t, jo)
</code></pre></div></div>

<p>Four fixed permutations gather the bytes after <code class="language-plaintext highlighter-rouge">ShiftRows</code> and arrange the <code class="language-plaintext highlighter-rouge">MixColumns</code> contributions:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>m0 = permute(a2,       mix0)
m1 = permute(a XOR a2, mix1)
m2 = permute(a,        mix2)
m3 = permute(a,        mix3)

output = m0 XOR m1 XOR m2 XOR m3
</code></pre></div></div>

<p>How about the affine constant <code class="language-plaintext highlighter-rouge">0x63</code> we talked about earlier? Turns out that we can omit it.</p>

<p>A vector containing <code class="language-plaintext highlighter-rouge">0x63</code> in every byte is unchanged by <code class="language-plaintext highlighter-rouge">ShiftRows</code> and survives <code class="language-plaintext highlighter-rouge">MixColumns</code> because every output row’s coefficients XOR to one:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0x02 XOR 0x03 XOR 0x01 XOR 0x01 = 0x01
</code></pre></div></div>

<p>Just one final vector XOR restores <code class="language-plaintext highlighter-rouge">0x63</code>.</p>

<p>We can finally <code class="language-plaintext highlighter-rouge">AddRoundKey</code>, which is a simple XOR.</p>

<p>To do all this, we only need 15 byte permutations:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Input transformation:          2
Nested inversion:              5
S-Box output and its double:   4
ShiftRows and MixColumns:      4
                              --
Total:                        15
</code></pre></div></div>

<h2 id="swizzling-infinity">Swizzling infinity</h2>

<p>The inversion contains denominators that can be zero. So we need some kind of out-of-range selector to represent infinity.</p>

<p>The inverse tables map a zero denominator to <code class="language-plaintext highlighter-rouge">0x80</code>. XORing that with a nibble can produce anything from <code class="language-plaintext highlighter-rouge">0x80</code> through <code class="language-plaintext highlighter-rouge">0x8f</code>, but these values all represent the same infinity marker: bit 7 is set, while the low nibble doesn’t matter. When one of them selects from the next 16-byte table, the byte permutation returns zero.</p>

<p>And as mentioned previously, different targets treat out-of-range selectors differently.</p>

<table>
  <thead>
    <tr>
      <th>Primitive</th>
      <th>Out-of-range selector behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>x86 <code class="language-plaintext highlighter-rouge">PSHUFB</code></td>
      <td>If selector bit 7 is set, the result is zero; otherwise the low nibble selects</td>
    </tr>
    <tr>
      <td>AArch64 <code class="language-plaintext highlighter-rouge">TBL</code></td>
      <td>Every index above 15 returns zero</td>
    </tr>
    <tr>
      <td>WebAssembly strict SIMD</td>
      <td>Every index above 15 returns zero</td>
    </tr>
    <tr>
      <td>WebAssembly relaxed SIMD</td>
      <td><code class="language-plaintext highlighter-rouge">0x10</code> to <code class="language-plaintext highlighter-rouge">0x7f</code> may return zero or use the low nibble; <code class="language-plaintext highlighter-rouge">0x80</code> to <code class="language-plaintext highlighter-rouge">0xff</code> returns zero</td>
    </tr>
  </tbody>
</table>

<p>A selector between <code class="language-plaintext highlighter-rouge">0</code> and <code class="language-plaintext highlighter-rouge">15</code> or between <code class="language-plaintext highlighter-rouge">0x80</code> and <code class="language-plaintext highlighter-rouge">0xff</code> produces the same output everywhere, even in WebAssembly.</p>

<p>And the circuit described above only produces selectors in <code class="language-plaintext highlighter-rouge">0x00</code> through <code class="language-plaintext highlighter-rouge">0x0f</code> or <code class="language-plaintext highlighter-rouge">0x80</code> through <code class="language-plaintext highlighter-rouge">0x8f</code>. So, we’re safe!</p>

<p>The default WebAssembly “strict” SIMD mode doesn’t map well to x86 instructions, so the generated code is pretty inefficient.</p>

<p>Fortunately, WebAssembly later introduced a <code class="language-plaintext highlighter-rouge">relaxed-simd</code> variant of the instruction that marks selectors in the <code class="language-plaintext highlighter-rouge">0x10</code> through <code class="language-plaintext highlighter-rouge">0x7f</code> range as “don’t-care” values and lowers directly to a CPU instruction.</p>

<p>It’s a little sad that WebAssembly support is fragmented and relaxed SIMD isn’t supported everywhere yet, most notably in Safari. At least server runtimes such as Wasmer, WasmEdge, Wasmtime, and WAVM have supported it by default for a long time. And Safari will eventually catch up.</p>

<p>Is this technique worth it compared to lookup tables and bitslicing? It is. Compared to lookup tables, it guarantees constant-time execution. And compared to bitslicing, it doesn’t require bit-level representation changes and introduces less register pressure.</p>

<p>Most importantly, it’s a very good match for how some new AES-based ciphers such as AEGIS and HiAE operate. And we can finally have ciphers with half-decent performance on WebAssembly rumtimes without the WASI-Crypto extensions.</p>
 ]]></content:encoded>
    <guid isPermaLink="true">https://00f.net/2026/07/16/aes-with-simd-swizzles</guid>
  </item>
  
 </channel>
</rss>
