<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Authentication on Lessons learned</title><link>https://eghuro.cz/tags/authentication/</link><description>Recent content in Authentication on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Sat, 31 Oct 2026 01:19:28 +0100</lastBuildDate><atom:link href="https://eghuro.cz/tags/authentication/index.xml" rel="self" type="application/rss+xml"/><item><title>Waiting for logout before login still left a race inside the handshake</title><link>https://eghuro.cz/posts/logout-wait-race-inside-login-handshake/</link><pubDate>Mon, 21 Sep 2026 14:49:00 +0200</pubDate><guid>https://eghuro.cz/posts/logout-wait-race-inside-login-handshake/</guid><description>&lt;p&gt;Correction 2026-10-31: The earlier version attributed the reported failure to an idle timer. Later source review ruled out that timer for this app. The injected-teardown reproduction and exclusion-gate fix remain valid; the production trigger is unknown.&lt;/p&gt;&#10;&lt;p&gt;Users authenticated with Face ID, and the login failed anyway with a generic &amp;ldquo;login failed&amp;rdquo; error. The password was right, and the biometric enrollment was fine. The server&amp;rsquo;s log for the affected user showed the last handshake step arriving on a session in its initial state, with no user bound to it.&lt;/p&gt;&#10;&lt;p&gt;The app waited for any teardown already running before starting login. A dedicated test injected a new teardown after that wait and reproduced the same server-state signature. Holding an exclusion gate for the whole handshake fixed that reproduction, but did not establish the cause of the reported incident.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A stateful, multi-step login (SRP-style): start a session, send the identity, send the proof, finish. The server tracks progress per session cookie.&lt;/li&gt;&#10;&lt;li&gt;A &lt;strong&gt;session teardown&lt;/strong&gt;: sign out on the server, then clear the local cookie jar. &lt;code&gt;begin()&lt;/code&gt; returns immediately, and concurrent &lt;code&gt;begin()&lt;/code&gt; calls join one teardown.&lt;/li&gt;&#10;&lt;li&gt;An earlier fix for a related bug: before its handshake, login called &lt;code&gt;waitForCompletion()&lt;/code&gt; so a sign-out still in flight couldn&amp;rsquo;t destroy the new session.&lt;/li&gt;&#10;&lt;li&gt;The reproduction explicitly calls &lt;code&gt;begin()&lt;/code&gt; during the handshake. It does not rely on an idle timer.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sessionTeardown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waitForCompletion&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// once, before the handshake&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// four round trips&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="reproduce-with-the-real-stack"&gt;Reproduce with the real stack &lt;/h2&gt;&#10;&lt;p&gt;A dedicated live test drove the production login code against a development server and fired &lt;code&gt;begin()&lt;/code&gt; at a fixed delay into the handshake. At 500 ms:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;start -&amp;gt; {&amp;#34;state&amp;#34;:&amp;#34;new&amp;#34;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;send identity -&amp;gt; {&amp;#34;state&amp;#34;:&amp;#34;identified&amp;#34;, &amp;#34;salt&amp;#34;:...}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;send proof (M1) -&amp;gt; {&amp;#34;state&amp;#34;:&amp;#34;proof-verified&amp;#34;, &amp;#34;M2&amp;#34;:...}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;logout() &amp;lt;- the teardown fires here&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;POST /logout&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;finish (keys sent)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Logout success&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Session teardown: cookie jar cleared&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Error: state &amp;#34;new&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; failed(.other)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This matched the server-state signature logged for the user. In the reproduction, the one-time wait had nothing to wait for: no teardown was running when login started. The test started one between two round trips.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-trap-inside-the-trap-which-effect-does-the-damage"&gt;The trap inside the trap: which effect does the damage &lt;/h2&gt;&#10;&lt;p&gt;The code comments blamed the &lt;strong&gt;cookie-jar clear&lt;/strong&gt;: the concluding step would go out without a session cookie and land on an anonymous session. That would suggest a small fix: defer only the local cleanup.&lt;/p&gt;&#10;&lt;p&gt;In the reproduction&amp;rsquo;s trace, the cookie jar was cleared &lt;em&gt;after&lt;/em&gt; the finish step had already gone out with valid cookies. The &lt;strong&gt;server sign-out&lt;/strong&gt; reset the half-authenticated session, and the concluding step then arrived at a session the server had put back to &lt;code&gt;new&lt;/code&gt;. Deferring only the jar clear would still fail this exact trace.&lt;/p&gt;&#10;&lt;p&gt;Before you choose what to defer, read the ordering on the wire. The local effect you can see easily may not be the one that broke things.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix-hold-exclusion-for-the-whole-protocol"&gt;Fix: hold exclusion for the whole protocol &lt;/h2&gt;&#10;&lt;p&gt;The teardown gets a gate. Login holds it from the first round trip to the last. A &lt;code&gt;begin()&lt;/code&gt; that arrives while the gate is held is honored but deferred—neither its sign-out nor its cookie clear runs until the handshake releases the gate.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;TeardownState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;inFlight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Never&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;handshakes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="c1"&gt;// a count, so overlapping logins can&amp;#39;t release each other&amp;#39;s gate&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isHeldBack&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withValue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// join the running one&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isHeldBack&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runTeardown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// clears inFlight when done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;beginHandshake&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Wait out a running teardown, then claim the gate. Loop, because the slot&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// is only free on a later pass.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;running&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withValue&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Never&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;endHandshake&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withValue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handshakes&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isHeldBack&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isHeldBack&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inFlight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;runTeardown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;excludingTeardown&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="kc"&gt;_&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;async&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;beginHandshake&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;endHandshake&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Login wraps every credential type in it, so a new login surface can&amp;rsquo;t skip it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sessionTeardown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;excludingTeardown&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The other properties stayed the same. &lt;code&gt;begin()&lt;/code&gt; still returns immediately. A second &lt;code&gt;begin()&lt;/code&gt; still means one teardown, whether that teardown is running or held back. A teardown whose sign-out throws still releases its waiters. Logout still happens: it runs right after login, which then ends in a signed-out state on purpose.&lt;/p&gt;&#10;&lt;p&gt;Later source review found that this app created its idle timer only after the full login completed and dropped it when the login screen appeared. The shipped revision had the same wiring. The earlier claim that this timer disproved a signed-in-only assumption was withdrawn. The gate provides exclusion without depending on that reachability argument.&lt;/p&gt;&#10;&lt;p&gt;A sibling app did retain its timer after logout and needed a signed-in guard. That was a separate timer-lifetime defect, not evidence that the timer caused this app&amp;rsquo;s reported failure.&lt;/p&gt;&#10;&#10;&lt;h2 id="verification"&gt;Verification &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Unit test for the new direction.&lt;/strong&gt; A teardown begun inside &lt;code&gt;excludingTeardown&lt;/code&gt; runs neither effect until the body returns. The expected order is &lt;code&gt;[handshake, signOut, clearCookieJar]&lt;/code&gt;. Inside the body, the test awaits &lt;code&gt;waitForCompletion()&lt;/code&gt; rather than &lt;code&gt;Task.yield()&lt;/code&gt;, so a buggy teardown is guaranteed a chance to run. A yield would leave that to the scheduler.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Unit test for the old direction.&lt;/strong&gt; A teardown already running still finishes before the handshake starts. That is the guarantee the earlier fix provided.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Injected-teardown sweep.&lt;/strong&gt; The reproduction fires the teardown at 100, 200, 300, 400, 500 and 700 ms. It had been marked &lt;code&gt;XCTExpectFailure&lt;/code&gt;. After the fix, every delay logged in:&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;teardown at 100ms -&amp;gt; loggedIn&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;teardown at 200ms -&amp;gt; loggedIn&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;teardown at 700ms -&amp;gt; loggedIn&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The expected-failure marker was removed in the same commit.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-this-doesnt-prove"&gt;What this doesn&amp;rsquo;t prove &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The reproduction establishes a failure mechanism and verifies the exclusion fix. It does not prove that this mechanism caused the reported incident or identify its triggering call. We ruled out the idle timer for this app; the field trigger remained unknown.&lt;/li&gt;&#10;&lt;li&gt;The dedicated reproduction and the unit tests verify the fix. The live test class still exited with failures. They came from a separate host-app dependency problem in the test process, and another test in that class still tracks an unrelated second-login bug. Don&amp;rsquo;t read &amp;ldquo;the sweep passes&amp;rdquo; as &amp;ldquo;the live suite is green&amp;rdquo;.&lt;/li&gt;&#10;&lt;li&gt;The ordinary test plan passed, but live tests are opt-in there, so it says nothing about the server interaction.&lt;/li&gt;&#10;&lt;li&gt;Which delays hit the window depends on latency to the server. A sweep is better than a single delay, but it is still a sample.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="takeaways"&gt;Takeaways &lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Waiting once for cleanup before a multi-step protocol protects only that instant. If cleanup can &lt;em&gt;start&lt;/em&gt; during the protocol, exclude it for the whole protocol.&lt;/li&gt;&#10;&lt;li&gt;Defer cleanup, don&amp;rsquo;t drop it. Queue the request and run it when the gate is released.&lt;/li&gt;&#10;&lt;li&gt;Count holders instead of using a flag, so two overlapping logins can&amp;rsquo;t release the gate from under each other.&lt;/li&gt;&#10;&lt;li&gt;Read the wire ordering before you choose what to defer. Here, the server sign-out did the damage, not the local cookie clear that everyone suspected.&lt;/li&gt;&#10;&lt;li&gt;A matching reproduction does not identify the production trigger. Trace the initiating call in the affected build before naming it as the cause of an incident.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>When the server must compute on confidential data, split the data by who may read it</title><link>https://eghuro.cz/posts/split-confidential-data-by-reader/</link><pubDate>Sun, 21 Jun 2026 20:00:00 +0200</pubDate><guid>https://eghuro.cz/posts/split-confidential-data-by-reader/</guid><description>&lt;p&gt;I designed a web app that computes a person&amp;rsquo;s tax on their investment trades. Professional advisory firms run it for their own clients. Clients connect broker APIs or upload statements, and the server runs lot matching, currency conversion, and the holding-period tests. A client with a messy case can hand it to an advisor.&lt;/p&gt;&#10;&lt;p&gt;The confidentiality requirement may sound contradictory. The advisor owes the client professional secrecy from the first contact. The platform operator should not be able to read client data at rest, and the same goes for a stolen backup, a tenant boundary bug, or a subpoena served on the platform. The server still has to compute on all of it, including in background jobs while the user is offline.&lt;/p&gt;&#10;&lt;p&gt;Neither usual answer works:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Disk or database encryption&lt;/strong&gt; protects against a stolen disk. The operator, its DBAs, and a subpoena still get plaintext.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Client-side end-to-end encryption&lt;/strong&gt; fails because the server is the calculator. Background sync, recomputation after new data, and the advisor&amp;rsquo;s view all need plaintext on the server. In a web app it is weaker than it sounds anyway: the server ships the JavaScript on every load, so an operator that serves malicious code gets the password.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;What worked was a question I had skipped: which fields does the computation actually read?&lt;/p&gt;&#10;&#10;&lt;h2 id="list-what-the-engine-needs"&gt;List what the engine needs &lt;/h2&gt;&#10;&lt;p&gt;For every realized event, the tax engine needs a date, a quantity, amounts in the reporting currency, fees, an event type, and an asset category (security, crypto, derivative). To match lots, it has to know that two trades are the same instrument. It doesn&amp;rsquo;t need the instrument&amp;rsquo;s name, ticker, or ISIN, and it doesn&amp;rsquo;t need the broker, account number, or person.&lt;/p&gt;&#10;&lt;p&gt;One step needs real identity: classifying an instrument into a category. A broker may report a CFD under &amp;ldquo;stocks&amp;rdquo;, and a share can be fractional or whole. That happens once, at ingestion, inside a per-broker adapter. The adapter sees identity in memory, emits a de-identified, categorized record, and the identity goes elsewhere.&lt;/p&gt;&#10;&lt;p&gt;So the data splits into two zones, each with its own readers:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Zone 1: tax skeleton&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Zone 2: identity vault&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Contents&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;surrogate instrument ID, category, dates, quantity, amounts, fees, event type&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;surrogate → name/ticker/ISIN, broker, account, personal data&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Encrypted under&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;per-tenant key encryption key in a KMS (envelope encryption)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;key chain rooted in the user&amp;rsquo;s password&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Readers&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;engine (in memory, during compute), the firm after handoff&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;the user in session, the firm after handoff&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Platform operator&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;ciphertext at rest; if it decrypts, it gets anonymous arithmetic&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;ciphertext only&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Background jobs&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;read and write&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;write only, through a public-key layer&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Lost password&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;survives; the case can still be computed and filed&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;lost unless the user has the recovery code&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Zone 1 is intentionally not under the user&amp;rsquo;s key. Under the user&amp;rsquo;s key, the background sync, the in-year recompute, and the advisor&amp;rsquo;s computation would all stop working as soon as the user logged out. Background jobs write to Zone 2 by encrypting to the user&amp;rsquo;s public key. Only a logged-in user can decrypt.&lt;/p&gt;&#10;&#10;&lt;h2 id="surrogate-ids-a-keyed-hash-scoped-per-user"&gt;Surrogate IDs: a keyed hash, scoped per user &lt;/h2&gt;&#10;&lt;p&gt;Lot matching groups trades by instrument, so the skeleton needs a stable ID that is not the instrument. The design is an HMAC with a per-user key held in the KMS:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Stable across one user&amp;#39;s accounts and years, unlinkable across users.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;deriveSurrogateInstrumentId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;instrument&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;broker&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;instrument&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;broker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;surrogate requires instrument identity and broker&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;kmsHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`surrogate:user:&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;instrument&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;broker&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each alternative breaks one requirement:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A plain hash of the ISIN falls to a dictionary attack. The set of listed instruments is small, so hashing them all takes seconds.&lt;/li&gt;&#10;&lt;li&gt;One global HMAC key makes the same instrument correlate across every user in a dump.&lt;/li&gt;&#10;&lt;li&gt;A random ID per trade cannot be recomputed. Next year&amp;rsquo;s sync, or a second account at another broker, has to land on the same ID without reading the vault, or carried-in lots stop matching.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The keyed hash protects a database dump. An operator who can call the KMS live can still test &amp;ldquo;does this user hold this instrument&amp;rdquo;. That is past the threat model&amp;rsquo;s at-rest boundary, so write it down instead of claiming more.&lt;/p&gt;&#10;&#10;&lt;h2 id="access-and-liability-begin-at-the-same-instant"&gt;Access and liability begin at the same instant &lt;/h2&gt;&#10;&lt;p&gt;Before handoff, the firm&amp;rsquo;s advisors have no access to a prospect&amp;rsquo;s case. For identity, the user&amp;rsquo;s key enforces that cryptographically. The anonymous skeleton sits under the firm&amp;rsquo;s own key, so for it, access control is the barrier. It matches the legal position: no engagement contract, no professional liability, only precontractual confidentiality.&lt;/p&gt;&#10;&lt;p&gt;Handoff is one atomic event. The engagement contract forms, the firm&amp;rsquo;s anti-money-laundering (AML) and know-your-customer (KYC) duties start, and the vault&amp;rsquo;s relevant portion is rewrapped to the firm&amp;rsquo;s key. Data access and liability start together, so neither exists without the other.&lt;/p&gt;&#10;&lt;p&gt;The coding agent&amp;rsquo;s first draft at this step was wrong. It proposed default-minimal disclosure: share only the vault fields the flagged items needed. I rejected it because an advisor who takes on liability and an AML duty cannot sign off on a case half-blind. The boundary that held was &lt;strong&gt;purpose, not field sensitivity&lt;/strong&gt;: everything the past outcome was built from transfers in full, and anything forward-looking (plans, rebalancing ideas, notes) never transfers. Data minimization still applies, but the line runs between past and future, not through the case.&lt;/p&gt;&#10;&#10;&lt;h2 id="one-password-auto-unlock-never-transmitted-pick-an-apake"&gt;One password, auto-unlock, never transmitted: pick an aPAKE &lt;/h2&gt;&#10;&lt;p&gt;The first plan was standard session login plus a client-side KDF for the vault key, with OPAQUE as a later upgrade. That leaves two bad choices:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Two passwords&lt;/strong&gt;: one for login, sent to the server, and one for the vault, used only in the browser. Users hate it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;One password, still POSTed for login, also used to derive the vault key.&lt;/strong&gt; It gives auto-unlock, and it quietly hands the operator the vault password on every login. Zone 2 stops protecting anything.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;With a standard login, one password cannot also stay off the server. An asymmetric PAKE fixes the login side. &lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9807"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OPAQUE (RFC 9807)&lt;/a&gt;&#10; authenticates without the server learning the password, and gives the client an &lt;code&gt;export_key&lt;/code&gt; that the server never sees. Root the vault in it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// K_vault = HKDF(export_key); unwraps user key → RSA private key → vault entries&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;deriveVaultKeyBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exportKey&lt;/span&gt;: &lt;span class="kt"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Uint8Array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exportKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;export_key is required&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hkdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exportKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;K_VAULT_INFO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A password reset is a new OPAQUE registration with a new &lt;code&gt;export_key&lt;/code&gt;. The mandatory recovery code rewraps the vault chain to the new root, and Zone 1 is untouched. Use a vetted library: the registration and login flows and the &lt;code&gt;export_key&lt;/code&gt; handling are now part of the vault&amp;rsquo;s security.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-tenant-boundary-belongs-in-the-database"&gt;The tenant boundary belongs in the database &lt;/h2&gt;&#10;&lt;p&gt;Each firm is a tenant, and one firm&amp;rsquo;s prospects must never show up in another firm&amp;rsquo;s queries. A forgotten &lt;code&gt;WHERE firm_id = ?&lt;/code&gt; should return nothing, not another tenant&amp;rsquo;s rows. With Postgres row-level security, three details decide whether it fails closed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;alter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;app_migrator&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- the app role is not the owner&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;alter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;force&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;row&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;level&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- the owner obeys policies too&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;firm_isolation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;using&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firm_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;app.firm_id&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;check&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firm_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;app.firm_id&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="sb"&gt;`select set_config(&amp;#39;app.firm_id&amp;#39;, &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;firmId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;, true)`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true = transaction-local&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;work&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;});&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ul&gt;&#10;&lt;li&gt;Table owners bypass RLS unless it is forced. Superusers and &lt;code&gt;BYPASSRLS&lt;/code&gt; roles bypass it always, so the app must connect as neither.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;current_setting&lt;/code&gt; without &lt;code&gt;missing_ok&lt;/code&gt;, plus the &lt;code&gt;::uuid&lt;/code&gt; cast, raises when the tenant was never set or is empty. A missing tenant is an error, not an empty match.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;set_config(..., true)&lt;/code&gt; resets at commit or rollback. With &lt;code&gt;false&lt;/code&gt;, a pooled connection carries one firm&amp;rsquo;s ID into the next request, and a transaction-mode pooler can hand the setting to a different client.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="write-down-the-floors"&gt;Write down the floors &lt;/h2&gt;&#10;&lt;p&gt;The design makes specific claims and lists what it does not cover. That list is part of the design:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Plaintext exists in memory during ingestion and compute. The claim is &amp;ldquo;zero to the operator at rest&amp;rdquo;, never &amp;ldquo;the platform never sees it&amp;rdquo;.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;amount ÷ quantity&lt;/code&gt; approximates a unit price that can be matched against market data. It is exploitable only by someone who already decrypted Zone 1.&lt;/li&gt;&#10;&lt;li&gt;The web JavaScript trust gap stays open. OPAQUE stops offline cracking of a stolen verifier table, not a malicious bundle. A native client is the eventual fix.&lt;/li&gt;&#10;&lt;li&gt;An API-connected account reveals which broker it is, because the token names it. A manual upload keeps the broker in Zone 2. The user gets that choice in the UI.&lt;/li&gt;&#10;&lt;li&gt;A forgotten password without the recovery code loses the labels. The skeleton survives, and the user is told to keep their own copies of the source statements.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;When the server has to be the calculator, don&amp;rsquo;t choose between &amp;ldquo;encrypt the disk&amp;rdquo; and &amp;ldquo;encrypt everything client-side&amp;rdquo;. List the readers (user, professional, engine, operator) and what each one needs. Then check whether the computation can run without identity. When it can, identity moves into a zone the computation never opens, and the remaining decisions follow from that: keyed surrogates, a handoff that rewraps keys, an aPAKE for the single password.&lt;/p&gt;&#10;&lt;p&gt;In practice, I ran the design as an interview with a coding agent. It asked one question at a time, proposed options with a recommendation, and recorded each decision as an ADR with a &amp;ldquo;Why this is non-obvious&amp;rdquo; section: the design a reader would expect, and why it was rejected here. Several recommendations were reasonable but wrong. Two of my corrections removed whole planned pieces: the default-minimal handoff, and an &lt;a&#10; href="https://eghuro.cz/posts/check-the-attribute-reaches-the-computation"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;instrument catalog&lt;/a&gt;&#10; that event-driven classification made unnecessary.&lt;/p&gt;&#10;</description></item><item><title>Codex 401 in an agent shell: check credentials before blaming API key precedence</title><link>https://eghuro.cz/posts/codex-inherited-api-key/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/codex-inherited-api-key/</guid><description>&lt;p&gt;A regular terminal reported a ChatGPT login, but Codex requests inside an agent&amp;rsquo;s shell failed with:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;HTTP error: 401 Unauthorized&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An inherited &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; looked suspicious. The useful next step was a controlled credential-context comparison. The recorded investigation never proved that the key overrode the ChatGPT login, and it never showed a successful retry after removing it.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-the-may-5-investigation-established"&gt;What the May 5 investigation established &lt;/h2&gt;&#10;&lt;p&gt;The installed CLI was &lt;code&gt;codex-cli 0.128.0&lt;/code&gt;. The user reported that &lt;code&gt;codex login status&lt;/code&gt; returned &lt;code&gt;Logged in using ChatGPT&lt;/code&gt; before launching the agent. Inside the agent, the same status command returned &lt;code&gt;Not logged in&lt;/code&gt;, and this request repeatedly failed:&lt;/p&gt;&#10;&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;codex &lt;span class="nb"&gt;exec&lt;/span&gt; --skip-git-repo-check &lt;span class="s2"&gt;&amp;#34;echo hello&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An early environment check found &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; present. A separate request to the OpenAI models endpoint, explicitly using that variable, returned HTTP 401. That established that the credential failed that direct probe. It did not establish which credential Codex selected.&lt;/p&gt;&#10;&lt;p&gt;Later checks found the variable absent, yet Codex still failed. One retry explicitly supplied an empty &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; and also received 401 responses. The chain contains no controlled &lt;code&gt;env -u&lt;/code&gt; comparison that restored subscription access. The document-cleanup task continued with Python; Codex authentication was not shown to recover.&lt;/p&gt;&#10;&lt;p&gt;Calling this a confirmed case of an environment key shadowing OAuth would overstate the evidence. The login-status mismatch deserved investigation before declaring the subscription token expired or replacing it with an API key.&lt;/p&gt;&#10;&#10;&lt;h2 id="compare-the-launch-contexts-without-exposing-secrets"&gt;Compare the launch contexts without exposing secrets &lt;/h2&gt;&#10;&lt;p&gt;Run these checks locally in both the working terminal and the failing agent shell:&lt;/p&gt;&#10;&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="nb"&gt;command&lt;/span&gt; -v codex&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;codex --version&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;codex login status&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 - &lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;PY&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;import os&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;for name in (&amp;#34;HOME&amp;#34;, &amp;#34;CODEX_HOME&amp;#34;, &amp;#34;OPENAI_API_KEY&amp;#34;, &amp;#34;CODEX_API_KEY&amp;#34;):&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; print(f&amp;#34;{name}: {&amp;#39;set&amp;#39; if name in os.environ else &amp;#39;unset&amp;#39;}&amp;#34;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;PY&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Keep login-status output private if it includes credential details. The Python check prints names and presence only. Do not dump the environment, search credential files, or paste tokens into a troubleshooting conversation.&lt;/p&gt;&#10;&lt;p&gt;Compare the actual &lt;code&gt;HOME&lt;/code&gt; and &lt;code&gt;CODEX_HOME&lt;/code&gt; paths privately, too. Presence alone cannot tell you whether two processes use the same directory. Check the executable and launch configuration for differences before changing authentication.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://developers.openai.com/codex/auth"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OpenAI&amp;rsquo;s authentication documentation&lt;/a&gt;&#10; distinguishes ChatGPT subscription sign-in from API-key access. It also describes credential storage in a file under &lt;code&gt;CODEX_HOME&lt;/code&gt; or an OS credential store. A &lt;a&#10; href="https://eghuro.cz/posts/profile-home-tilde-paths"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;profile HOME mismatch&lt;/a&gt;&#10; or unavailable credential store is a separate explanation for a missing login.&lt;/p&gt;&#10;&lt;p&gt;To isolate the suspected variable on a Unix-like system, keep the same executable, working directory, &lt;code&gt;HOME&lt;/code&gt;, and &lt;code&gt;CODEX_HOME&lt;/code&gt;, then compare:&lt;/p&gt;&#10;&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;codex login status&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;env -u OPENAI_API_KEY codex login status&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Removing one variable affects only the second process. It does not delete the saved login or change the parent shell. If you intend to make a model request, repeat the same minimal prompt with and without that variable and compare completion and exit status. A status check alone does not prove a request will succeed. This paired request test was not completed in the May investigation.&lt;/p&gt;&#10;&#10;&lt;h2 id="verification-on-october-31-avoid-a-universal-precedence-rule"&gt;Verification on October 31: avoid a universal precedence rule &lt;/h2&gt;&#10;&lt;p&gt;A separate check on 2026-10-31 used installed Codex &lt;code&gt;0.166.0&lt;/code&gt; with isolated, empty &lt;code&gt;HOME&lt;/code&gt; and &lt;code&gt;CODEX_HOME&lt;/code&gt; directories. &lt;code&gt;codex login status&lt;/code&gt; returned &lt;code&gt;Not logged in&lt;/code&gt; both without a key and with a dummy &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;. No model request or real credential was used. This checks status behavior without a saved login; it does not test precedence over OAuth.&lt;/p&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://github.com/openai/codex/blob/67f0994b532b8740f14b5b738af7b7b2a915b1d6/codex-rs/login/src/auth/manager.rs#L1571-L1593"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;upstream authentication loader&lt;/a&gt;&#10;, inspected that day, has an environment-key precedence branch for &lt;code&gt;CODEX_API_KEY&lt;/code&gt;, conditional on that mechanism being enabled and API-key authentication being allowed. That is not a universal rule about &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;, nor proof of what version &lt;code&gt;0.128.0&lt;/code&gt; selected in May.&lt;/p&gt;&#10;&lt;p&gt;The earlier trusted-directory error was separate: &lt;code&gt;--skip-git-repo-check&lt;/code&gt; bypassed that check and exposed the authentication failure. Initializing unrelated directories as Git repositories would not repair credentials.&lt;/p&gt;&#10;&lt;p&gt;Before logging out or deleting a working login, compare the credential context of your two launch paths and change one variable at a time.&lt;/p&gt;&#10;</description></item></channel></rss>