<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Juan Carlos Isaza</title>
    <description>The latest articles on DEV Community by Juan Carlos Isaza (@isazajuancarlos).</description>
    <link>https://dev.to/isazajuancarlos</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4059817%2F1ee73c68-4e15-4926-951a-2c512db40d71.png</url>
      <title>DEV Community: Juan Carlos Isaza</title>
      <link>https://dev.to/isazajuancarlos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/isazajuancarlos"/>
    <language>en</language>
    <item>
      <title>My adaptive memory stayed empty in production, and it wasn't a bug</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Mon, 07 Sep 2026 18:25:48 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/my-adaptive-memory-stayed-empty-in-production-and-it-wasnt-a-bug-1hnj</link>
      <guid>https://dev.to/isazajuancarlos/my-adaptive-memory-stayed-empty-in-production-and-it-wasnt-a-bug-1hnj</guid>
      <description>&lt;p&gt;I had a table in the database that was supposed to fill itself. Its job was to &lt;strong&gt;learn from failures&lt;/strong&gt;: every time the system tried a variant of something and it didn't work, it saved it, to recycle later in another context where it might. A laboratory of failed attempts, piling up.&lt;/p&gt;

&lt;p&gt;In production it had &lt;strong&gt;zero rows&lt;/strong&gt;. It had been deployed for days and hadn't saved a single record. Meanwhile a neighbouring table —another memory, the one that notes which work is already exhausted so as not to repeat it— was growing normally.&lt;/p&gt;

&lt;p&gt;The temptation is obvious: there's a bug in the write. I went looking for it, and it wasn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero rows isn't the same as a write error
&lt;/h2&gt;

&lt;p&gt;The path that saves into that table emits a warning if the write fails. I searched the logs for those warnings: &lt;strong&gt;zero&lt;/strong&gt;. No write had failed.&lt;/p&gt;

&lt;p&gt;That's a fact, not an absence of one. If the path had been taken and had failed, it would have left a trace. Zero traces &lt;strong&gt;and&lt;/strong&gt; zero rows fit only one explanation: the write path &lt;strong&gt;never ran&lt;/strong&gt;. Not ran-and-failed. Didn't run.&lt;/p&gt;

&lt;p&gt;That's the difference between a real negative and a negative that was never put to the test, and they look the same unless you look for the &lt;strong&gt;positive control&lt;/strong&gt; —something the log WOULD show if the path had been taken—. Without it, "healthy and quiet" and "dead" look identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two mechanisms starving each other
&lt;/h2&gt;

&lt;p&gt;Why didn't it run? Because of another mechanism, upstream, doing its job well.&lt;/p&gt;

&lt;p&gt;That system has a &lt;strong&gt;negative memory&lt;/strong&gt;: when it exhausts everything it knows how to try against a target, it notes it down, so as not to spend effort again on something it already knows won't pay. It's a sensible optimisation. But it sat &lt;strong&gt;before&lt;/strong&gt; the phase that generated new variants —the phase that, on failing, would have fed the library—. As soon as a target went "exhausted", that phase was &lt;strong&gt;skipped entirely&lt;/strong&gt;. And if the phase never runs, it never produces a failure to save.&lt;/p&gt;

&lt;p&gt;Each mechanism, on its own, is correct. The negative memory avoids useless work. The library learns from failures. Together they formed a &lt;strong&gt;deadlock&lt;/strong&gt;: the first starved the second. The library could only fill from a phase the other one switched off.&lt;/p&gt;

&lt;p&gt;It's a recurring pattern: when you add a mechanism to contain what another one does, and the system starts behaving strangely, &lt;strong&gt;suspect the design before the code&lt;/strong&gt;. The symptom —a table at zero— looked like a bug; it was a tension between two pieces that are each fine on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  A value with two meanings is two fields
&lt;/h2&gt;

&lt;p&gt;There was a second problem, and it's the more instructive one. The library only saved &lt;strong&gt;one kind&lt;/strong&gt; of failure: the one where the system processed the attempt and returned a normal result, without success. The other kind —when the attempt was &lt;strong&gt;blocked&lt;/strong&gt; up front, without ever being processed— it discarded, with a reasonable argument: a block says more about the target's posture than about the variant.&lt;/p&gt;

&lt;p&gt;But that argument falls apart when recycling. The variant blocked at one target is an &lt;strong&gt;untried candidate&lt;/strong&gt; at another, with a different configuration. Discarding it was, precisely, failing to learn from the most common failure.&lt;/p&gt;

&lt;p&gt;The fix was not to collapse the two meanings into one. "Processed failure" and "blocked failure" are &lt;strong&gt;two distinct signals&lt;/strong&gt;, so they go in &lt;strong&gt;two distinct fields&lt;/strong&gt;: two counters. That way the library saves both, and whatever recycles them orders them —the processed ones, more informative, first; the blocked ones, at the back, but present—. A value with two meanings isn't one field: it's two.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was left
&lt;/h2&gt;

&lt;p&gt;Two fixes on the same symptom. Neither touched the write, which was always correct. One broke the deadlock —letting the phase feed the library even when the target is "exhausted"—; the other split the two faces of failure. And the table at zero, which looked like the bug, was the messenger: a path that never ran and a condition too narrow, seen through the same hole.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>I killed the process and the drain still hung: a grandchild held the pipe</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:29:06 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/i-killed-the-process-and-the-drain-still-hung-a-grandchild-held-the-pipe-11k9</link>
      <guid>https://dev.to/isazajuancarlos/i-killed-the-process-and-the-drain-still-hung-a-grandchild-held-the-pipe-11k9</guid>
      <description>&lt;p&gt;A program of mine hung for forty minutes. Not spinning at a thousand loops a second: at &lt;strong&gt;zero percent CPU&lt;/strong&gt;. It wasn't doing too much work; it wasn't doing any work at all. And still it wouldn't finish.&lt;/p&gt;

&lt;p&gt;The program does something common: it orchestrates external command-line tools. It launches one, reads what it writes to standard output, and moves on to the next when it's done. So it doesn't get stuck when a tool drags, each one has a timeout: when it fires, the process is killed and we carry on.&lt;/p&gt;

&lt;p&gt;That's the part that failed, and it failed where no one looks: &lt;strong&gt;after&lt;/strong&gt; killing the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Killing the process doesn't close the pipe
&lt;/h2&gt;

&lt;p&gt;When you read a subprocess's output, you read from a &lt;strong&gt;pipe&lt;/strong&gt;: one end writes (the subprocess), the other reads (you). Your reader doesn't finish when the subprocess dies. It finishes when &lt;strong&gt;EOF&lt;/strong&gt; arrives, and a pipe's EOF arrives only when the &lt;strong&gt;last&lt;/strong&gt; write end is closed.&lt;/p&gt;

&lt;p&gt;Almost always they coincide: the subprocess is the only writer, it dies, its end closes, EOF arrives, your reader finishes. All in microseconds.&lt;/p&gt;

&lt;p&gt;But "almost always" isn't "always". The tool I launched &lt;strong&gt;launched another one in turn&lt;/strong&gt; —a grandchild—. And that grandchild &lt;strong&gt;inherited&lt;/strong&gt; the pipe's write end, because on Unix a child inherits its parent's open descriptors unless told otherwise.&lt;/p&gt;

&lt;p&gt;So when the timeout fired, I killed the child. Its end closed. But the &lt;strong&gt;grandchild was still alive&lt;/strong&gt;, with its copy of the descriptor open. The last write end hadn't closed. EOF never came. And my reader sat waiting for an EOF that would &lt;strong&gt;never&lt;/strong&gt; arrive —at zero percent CPU, blocked in a &lt;code&gt;read()&lt;/code&gt;, indistinguishable from slow work—.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom that deceives
&lt;/h2&gt;

&lt;p&gt;What makes this failure so hard to see is that it &lt;strong&gt;doesn't look like a failure&lt;/strong&gt;. An infinite-loop hang burns CPU: you see it in &lt;code&gt;top&lt;/code&gt; instantly. This one spends nothing. The thread is asleep in the kernel waiting for data that isn't coming. In the process list it looks healthy. In the metrics it looks like it's "taking a while". The only way to tell "hung forever" from "running slow" is to look at the &lt;strong&gt;thread stack&lt;/strong&gt; and see the &lt;code&gt;read()&lt;/code&gt; that never moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix isn't killing better
&lt;/h2&gt;

&lt;p&gt;The reflex is to try to kill the grandchildren too —a process group, a session, the whole tree—. Sometimes you can, sometimes you can't: a process can escape the group, a grandchild can outlive its parent on purpose.&lt;/p&gt;

&lt;p&gt;But there's a simpler and more honest way out, and it comes from a single question: &lt;strong&gt;which process, exactly, is going to close that descriptor?&lt;/strong&gt; If the answer is "none that I control", then waiting for EOF is waiting for something that won't happen. And a wait on something that won't happen &lt;strong&gt;isn't fixed by waiting better: it's bounded&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The fix was to put a timeout on the drain itself. After killing the process, we wait for EOF &lt;strong&gt;for a few seconds&lt;/strong&gt;; if it doesn't come, we abandon the read and move on. Whatever the tool managed to write before is already captured, so nothing is lost. And a descriptor a grandchild keeps open stops mattering: no one waits on it indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, beyond pipes
&lt;/h2&gt;

&lt;p&gt;Every wait —a loop that spins until a condition holds, an &lt;code&gt;await&lt;/code&gt; on a descriptor— has one question behind it: &lt;strong&gt;which process is going to make that condition true?&lt;/strong&gt; If there's none, the wait is impossible, and an impossible wait &lt;strong&gt;gives no error&lt;/strong&gt;: it looks exactly like work that's taking a while. Zero percent CPU, not advancing, forever.&lt;/p&gt;

&lt;p&gt;Before you write the wait, name the process that will end it. If you can't name it, don't write it without a cap.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>Fifty seconds for half a megabyte: the optimisation that fixed the constant, not the order</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Sat, 05 Sep 2026 18:29:16 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/fifty-seconds-for-half-a-megabyte-the-optimisation-that-fixed-the-constant-not-the-order-2afe</link>
      <guid>https://dev.to/isazajuancarlos/fifty-seconds-for-half-a-megabyte-the-optimisation-that-fixed-the-constant-not-the-order-2afe</guid>
      <description>&lt;p&gt;A cryptography library had a bottleneck &lt;strong&gt;no test could see&lt;/strong&gt;: encrypting half a&lt;br&gt;
megabyte took fifty seconds. Every test passed. They had been passing for months.&lt;/p&gt;

&lt;p&gt;The cause is a trap that keeps recurring: &lt;strong&gt;a correct, well-documented&lt;br&gt;
optimisation that fixes the constant and not the order&lt;/strong&gt; — and whose comment,&lt;br&gt;
precisely because it is well written, convinces the reader the problem is already&lt;br&gt;
solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the code did
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/en/quipu/code"&gt;Quipu&lt;/a&gt; renders encrypted data as a sequence of symbols. To do&lt;br&gt;
that it converts the whole message into a single huge integer and repeatedly&lt;br&gt;
divides it to extract digits, the same way you would convert a base-10 number to&lt;br&gt;
base 2 by hand.&lt;/p&gt;

&lt;p&gt;The code did not divide one digit at a time. It carried a sensible optimisation:&lt;br&gt;
divide by the largest power of the base that fits in a machine word, extracting&lt;br&gt;
nine digits per pass instead of one. The comment explaining it &lt;strong&gt;opened by saying&lt;br&gt;
that doing it one at a time would be quadratic&lt;/strong&gt;, and then described the&lt;br&gt;
improvement.&lt;/p&gt;

&lt;p&gt;All true. And the result was still quadratic: extracting nine digits per pass&lt;br&gt;
divides the work by nine; it does not change how the work grows.&lt;/p&gt;

&lt;p&gt;That sentence — "doing it this way would be quadratic" — reads in the past tense,&lt;br&gt;
as if it described the previous state. It described the current one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement, which is the only thing that says so
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Factor per doubling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;64 KiB&lt;/td&gt;
&lt;td&gt;0.79 s&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;128 KiB&lt;/td&gt;
&lt;td&gt;3.16 s&lt;/td&gt;
&lt;td&gt;×4.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256 KiB&lt;/td&gt;
&lt;td&gt;12.6 s&lt;/td&gt;
&lt;td&gt;×4.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;512 KiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;50.7 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;×4.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exactly four, three times running. That is textbook quadratic: every time the&lt;br&gt;
input doubles, the time quadruples. Extrapolating, ten megabytes would have cost&lt;br&gt;
about &lt;strong&gt;five and a half hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And here is the point: &lt;strong&gt;a correctness test sees none of this&lt;/strong&gt;. A slow algorithm&lt;br&gt;
produces exactly the same bytes as a fast one. The suite stayed green, and would&lt;br&gt;
have stayed green forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is two hundred years old
&lt;/h2&gt;

&lt;p&gt;Nothing had to be invented. &lt;strong&gt;Divide-and-conquer&lt;/strong&gt; radix conversion is a classical&lt;br&gt;
algorithm: instead of peeling digits off one end, you split the number in half —&lt;br&gt;
dividing by a power with half as many digits — and repeat on each half. The tree&lt;br&gt;
has as many levels as the size has doublings, and each level costs one big&lt;br&gt;
multiplication instead of thousands of divisions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;512 KiB&lt;/td&gt;
&lt;td&gt;50,698 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;457 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;111×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 MiB&lt;/td&gt;
&lt;td&gt;~5.6 h&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41.4 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~490×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The factor per doubling dropped from 4.00 to &lt;strong&gt;2.82&lt;/strong&gt;, which is not an arbitrary&lt;br&gt;
number either: it is what you get from combining the tree with fast big-integer&lt;br&gt;
multiplication.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check BEFORE writing it
&lt;/h2&gt;

&lt;p&gt;And this is what separates an improvement from one that makes things worse:&lt;br&gt;
&lt;strong&gt;divide-and-conquer only pays off if your big-integer library's division is&lt;br&gt;
sub-quadratic.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If division is schoolbook, splitting in half and recursing is still quadratic —&lt;br&gt;
and with a worse constant than the loop you set out to replace. You would have&lt;br&gt;
written an algorithm that is more elegant, harder to read, and &lt;strong&gt;slower&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The library in use turned out to ship Burnikel-Ziegler recursive division, so it&lt;br&gt;
pays off. That gets checked before the first line is written, not after measuring&lt;br&gt;
a disappointing result.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to avoid this in the next repository
&lt;/h2&gt;

&lt;p&gt;The question that saves the five and a half hours is not "is this optimised?" but&lt;br&gt;
&lt;strong&gt;"did the ORDER change, or only the constant?"&lt;/strong&gt; — and it is answered with two&lt;br&gt;
sizes and one division, not by reading the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if doubling the input doubles the time, it is linear;&lt;/li&gt;
&lt;li&gt;if it quadruples, it is quadratic;&lt;/li&gt;
&lt;li&gt;if it goes up by ~2.8, there is a tree and a fast multiplication behind it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It costs a minute. And that measurement deserves to become a test that fails if&lt;br&gt;
someone reintroduces a loop: a cost regression is invisible to a correctness&lt;br&gt;
suite, because the code still gives the right answer — it just takes five hours.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>algorithms</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>I said no data was leaving. On the first good run, two records left</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Fri, 04 Sep 2026 18:42:58 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/i-said-no-data-was-leaving-on-the-first-good-run-two-records-left-5a2f</link>
      <guid>https://dev.to/isazajuancarlos/i-said-no-data-was-leaving-on-the-first-good-run-two-records-left-5a2f</guid>
      <description>&lt;p&gt;I was asked whether the system was sending patient data to an external body while the integration was half-built. I went and read the logs of every run. They all died early: some with a 415 because the content type wasn't what the other end expected, others with a 500. Not one showed an outbound call.&lt;/p&gt;

&lt;p&gt;I answered that nothing was going out.&lt;/p&gt;

&lt;p&gt;The first run that got past the 500 sent &lt;strong&gt;two requests carrying real clinical data&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My answer had been false from the start, and the worst part is that it was false in a way that felt rigorous: I had looked. I had evidence. The evidence was logs of real executions, not assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  A negative says nothing on its own
&lt;/h2&gt;

&lt;p&gt;The mistake wasn't misreading the logs. It was not noticing what produced that silence.&lt;/p&gt;

&lt;p&gt;The runs died &lt;strong&gt;before&lt;/strong&gt; reaching the code that sends. The log didn't say "I didn't send"; it said "I never got to the part that sends". Those are two different statements and they produce exactly the same output: nothing.&lt;/p&gt;

&lt;p&gt;That's the general shape of the problem, and it turns up everywhere once you look for it:&lt;/p&gt;

&lt;p&gt;A counter at zero can mean "it didn't happen" or "the counter was never incremented". A "not found" can mean "it doesn't exist" or "I looked in the wrong place". A green test can mean "it passed" or "it skipped itself". An &lt;code&gt;exit 0&lt;/code&gt; can mean "it worked" or "the command was strangled by a pipe that swallowed the exit code". A silent dashboard can mean "everything is fine" or "the process feeding it has been dead for three weeks".&lt;/p&gt;

&lt;p&gt;In all five, the evidence is identical. And in all five, the optimistic reading is the reassuring one, so it's the one chosen without thinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The positive control
&lt;/h2&gt;

&lt;p&gt;The fix isn't to be more suspicious. It's to demand one specific thing before accepting any negative:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Find something the log MUST show if the path was actually taken.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the system had reached the part that sends, something would have to appear in the log: the "preparing request" line, the batch identifier, the connection attempt. Any signal that is impossible without having gone through there.&lt;/p&gt;

&lt;p&gt;If that signal isn't there, you haven't demonstrated that it doesn't send. You've demonstrated that you don't know whether it sends. And "I don't know" is a perfectly respectable answer; "it doesn't send" was a lie.&lt;/p&gt;

&lt;p&gt;When that witness exists, the negative starts to count, because now it does separate the two worlds: got there and didn't send, versus never got there.&lt;/p&gt;

&lt;h2&gt;
  
  
  And you have to look again after the first success
&lt;/h2&gt;

&lt;p&gt;This is the second half, and in my case it was the expensive one.&lt;/p&gt;

&lt;p&gt;I looked after the failures. That's the natural thing: you investigate when something goes wrong. But the behaviour I'd been asked to check &lt;strong&gt;only appeared when things went right&lt;/strong&gt;, and that doesn't happen until someone fixes the 415 and the 500.&lt;/p&gt;

&lt;p&gt;So the complete rule has two moments: check that the path executed, and &lt;strong&gt;check again after the first successful run&lt;/strong&gt;, not only after the failed ones. The first green is the most dangerous moment in an integration, because that's when the new code finally runs end to end and nobody is watching: it has already been filed as solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anything that can stay silent must say why it is silent
&lt;/h2&gt;

&lt;p&gt;From this comes a design consequence that changed how I write anything that watches.&lt;/p&gt;

&lt;p&gt;A process that only speaks when it finds a problem is indistinguishable from a dead process. Both produce the same silence, and silence is exactly what we read as "all good".&lt;/p&gt;

&lt;p&gt;So a watcher that can legitimately stay quiet must emit, every cycle, &lt;strong&gt;why&lt;/strong&gt; it is quiet: "412 entries checked, 0 violations" instead of saying nothing. It costs one line. And it turns "healthy and quiet" into something that looks different from "dead", which is exactly what the watcher exists to distinguish.&lt;/p&gt;

&lt;p&gt;The same goes for tests: a suite that reports "0 failures" without saying how many tests it ran has said nothing. Zero out of zero is green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's left
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Before claiming something does &lt;strong&gt;not&lt;/strong&gt; happen, check that the code capable of doing it actually ran. If you can't check that, the answer is "cannot be determined", not "no".&lt;/li&gt;
&lt;li&gt;Find the witness: the signal the log would have to show if the path had been taken.&lt;/li&gt;
&lt;li&gt;Look again &lt;strong&gt;after the first success&lt;/strong&gt;, not only after the failures.&lt;/li&gt;
&lt;li&gt;Anything that can legitimately stay silent must say why it is silent, every cycle.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of the four costs money or any appreciable time. The one I was missing was the first, and the bill was two transmissions of patient data that I had certified did not exist.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>security</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>Don't claim a security boundary holds — demonstrate it</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Thu, 03 Sep 2026 18:20:16 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/dont-claim-a-security-boundary-holds-demonstrate-it-df2</link>
      <guid>https://dev.to/isazajuancarlos/dont-claim-a-security-boundary-holds-demonstrate-it-df2</guid>
      <description>&lt;p&gt;A system has to run a chunk of code you don't control —a plugin, a dependency, something generated— and you want to guarantee that code &lt;strong&gt;cannot&lt;/strong&gt; touch the file system or spawn processes. Not that it "shouldn't": that it &lt;strong&gt;can't&lt;/strong&gt;, mechanically. That's capability confinement, and it's one of the central problems of runtime security.&lt;/p&gt;

&lt;p&gt;Designing it and &lt;strong&gt;demonstrating&lt;/strong&gt; it are two different things, and confusing them is expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  A design is a claim
&lt;/h2&gt;

&lt;p&gt;You can write an impeccable document: "access to &lt;code&gt;fs&lt;/code&gt; and to spawning processes is controlled like this, with these mechanisms, under this threat model". It's real and necessary work. But it's a &lt;strong&gt;claim&lt;/strong&gt;. And the failure mode of a security boundary is that it &lt;strong&gt;looks like it holds until it doesn't&lt;/strong&gt; —silent, invisible in tests, visible only when someone crosses it—.&lt;/p&gt;

&lt;p&gt;In security, an unverified claim has exactly the shape of a beautiful, wrong architecture. The design can assume that a module-loader hook fires at a point where it actually doesn't, and all the reasoning hanging off that is correct and worth nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanisms exist, and there are several
&lt;/h2&gt;

&lt;p&gt;In Node, to name a concrete runtime, there are at least three layers, and they aren't interchangeable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;native permission model&lt;/strong&gt; (&lt;code&gt;--permission&lt;/code&gt;, &lt;code&gt;--allow-fs-read&lt;/code&gt;…), which cuts access to &lt;code&gt;fs&lt;/code&gt; and to spawning processes at the whole-process level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SES / Hardened JavaScript&lt;/strong&gt; (Compartments, &lt;code&gt;lockdown()&lt;/code&gt;), which confines what each module can import within the process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Module-loader interception&lt;/strong&gt;, which controls what resolves when the code asks for something.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choosing well among them is the design. But choosing well doesn't prove the choice holds against the real dependency tree you're going to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demonstrate instead of claim
&lt;/h2&gt;

&lt;p&gt;The alternative to signing off a design is delivering a &lt;strong&gt;confinement harness&lt;/strong&gt;: untrusted code that &lt;strong&gt;tries&lt;/strong&gt; to reach the dangerous capability —open a file, spawn a process— against the real runtime and its real dependency tree, and a log that shows each attempt was &lt;strong&gt;blocked&lt;/strong&gt;. Or, if something escapes, exactly where.&lt;/p&gt;

&lt;p&gt;The difference isn't cosmetic. "I think it's confined" and "here's the evidence that an attempt to read a system file from the plugin returned permission denied" are claims of very different weight. The second is the discipline I use for everything else applied to security: &lt;strong&gt;cross the program boundary and measure what comes out&lt;/strong&gt;, not what you think comes out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;There's a temptation to sign off the boundary from an authority you haven't earned —because the design looks good, because the mechanisms exist—. The honest discipline is the opposite: what you can back today, you back; what depends on a runtime detail you don't master, &lt;strong&gt;you don't assert from memory: you validate empirically&lt;/strong&gt;, and what can't be demonstrated yet is marked as the seam to co-review, not hidden.&lt;/p&gt;

&lt;p&gt;A demonstrated boundary covers exactly the gap a claimed one leaves open. And for whoever has to trust the confinement, a harness that attacks its own boundary and keeps the evidence is worth more than the word of whoever designed it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>testing</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>My detector was blind across 1,029 of 6,309 lines and its three tests stayed green</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Thu, 03 Sep 2026 01:35:07 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/my-detector-was-blind-across-1029-of-6309-lines-and-its-three-tests-stayed-green-4o2k</link>
      <guid>https://dev.to/isazajuancarlos/my-detector-was-blind-across-1029-of-6309-lines-and-its-three-tests-stayed-green-4o2k</guid>
      <description>&lt;p&gt;The guard worked. I had written it so that no session open in one project could touch another project's files, it had three tests, and all three were green. They had been green for months.&lt;/p&gt;

&lt;p&gt;It was blind across &lt;strong&gt;1,029 of the 6,309 lines&lt;/strong&gt; it was watching. Eight hundred and twenty-four of them executable.&lt;/p&gt;

&lt;p&gt;And the three tests kept passing, because all three looked at paths that were already clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The defect isn't in the detector: it's in how you test it
&lt;/h2&gt;

&lt;p&gt;You test a detector with cases. The natural thing is to take them from the system itself: feed it the real files and check that it finds nothing, because the system is healthy. A hundred cases, all green, and the feeling of having measured something.&lt;/p&gt;

&lt;p&gt;Nothing has been measured.&lt;/p&gt;

&lt;p&gt;A bench made &lt;strong&gt;only of negatives over artefacts that are clean today&lt;/strong&gt; comes out green with the correct rule and comes out green with a rule that doesn't look. A file with no violations returns empty in both worlds: the one where the detector works and the one where it broke three months ago. The two results are identical, so the bench cannot tell them apart.&lt;/p&gt;

&lt;p&gt;What that bench measures is &lt;strong&gt;the watched artefact&lt;/strong&gt;, not the watcher. And the watcher is precisely the piece whose failure never complains.&lt;/p&gt;

&lt;p&gt;It's the disguised form of a very familiar error, which is why it slips through: the tests exist, they are well written, they are readable, and they pass. There is nothing to flag in code review. The defect isn't in any line; it's in the set.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pair
&lt;/h2&gt;

&lt;p&gt;The fix is cheap and fits in one sentence: &lt;strong&gt;every probe ships with two cases, not one.&lt;/strong&gt; The one that MUST see something and the one that MUST NOT.&lt;/p&gt;

&lt;p&gt;With only the first, an instrument that says yes to everything passes just the same. With only the second, a blind one does. You need both, and you need them &lt;em&gt;together&lt;/em&gt;: each covers exactly the other's blind spot.&lt;/p&gt;

&lt;p&gt;And there are two conditions that get forgotten the moment you write the second case, and they are what separates a real pair from a decorative one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: the red case has to fail through the real path.&lt;/strong&gt; Breaking the rule in the test harness doesn't count. You have to break it in the code being measured, and require the test to actually go red. If mutilating the detector leaves the test green, what you were measuring was the harness. And it's worth checking that &lt;strong&gt;the mutant compiled&lt;/strong&gt;: a test that goes red because the code doesn't build has demonstrated nothing about the rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second: the detector is also tested against synthetic input.&lt;/strong&gt; The real files aren't enough. You have to fabricate a text that DOES contain the violation and another that doesn't, and push both through &lt;strong&gt;the same seam the real path uses&lt;/strong&gt;. If the test enters through another door — a helper, an already-constructed object — the red can come from the harness rather than the detector, and you're back where you started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mutant already exists, and it's free
&lt;/h2&gt;

&lt;p&gt;This is the part with the highest return per minute invested, and almost nobody uses it.&lt;/p&gt;

&lt;p&gt;When you fix a detector, the previous version of the rule &lt;strong&gt;is&lt;/strong&gt; the mutant you need. It's in history, one command away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git show HEAD:path/to/detector.py &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/previous_version.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the new pair of tests against that old version. If the red case goes red with it, the pair discriminates: it distinguishes the good rule from the blind one. If it comes out green against both, the new tests are worth no more than the old ones and nothing has been fixed yet.&lt;/p&gt;

&lt;p&gt;It costs a minute. It's the difference between believing you repaired a detector and knowing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the blind spot surfaced, which also teaches something
&lt;/h2&gt;

&lt;p&gt;No test found it. Pulling the opposite thread did.&lt;/p&gt;

&lt;p&gt;The guard was producing a &lt;strong&gt;false alarm&lt;/strong&gt;: it warned every time a command redirected to any file at all, even &lt;code&gt;/dev/null&lt;/code&gt;. Annoying and visible. While writing the missing case to fix that false positive, its silent twin appeared: writing directly into another project's file &lt;strong&gt;never warned at all&lt;/strong&gt;. It had been like that from day one.&lt;/p&gt;

&lt;p&gt;They weren't two defects. It was one, seen from both sides. The noisy side complained every day; the blind side was never going to complain.&lt;/p&gt;

&lt;p&gt;From which comes a practical rule that holds for any detector: &lt;strong&gt;when you fix a false positive, go looking for the false negative that shares its cause.&lt;/strong&gt; The noisy one leads you to the mute one, and the mute one is the expensive one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's left
&lt;/h2&gt;

&lt;p&gt;Three things, and none of them costs money:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A bench of only negatives does not discriminate, however many cases it has. If all your tests assert "there's nothing here", they come out green with the good rule and with a dead one.&lt;/li&gt;
&lt;li&gt;Every probe goes out with its pair, the red fails through the real path, and the mutant comes from history.&lt;/li&gt;
&lt;li&gt;Every noisy alarm hides a silent twin. Go find it when you fix the loud one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the uncomfortable part: this happened in a guard of &lt;strong&gt;mine&lt;/strong&gt;, written by me, with tests written by me. Discipline doesn't collapse while auditing someone else's work; it collapses exactly where the thing being checked is something you just wrote, because thinking it through feels like verification and isn't.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>security</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>Sealing a file so nobody can argue you touched it</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:24:09 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/sealing-a-file-so-nobody-can-argue-you-touched-it-ddc</link>
      <guid>https://dev.to/isazajuancarlos/sealing-a-file-so-nobody-can-argue-you-touched-it-ddc</guid>
      <description>&lt;p&gt;An argument about a digital file is almost never lost over what the file says. It is lost one question earlier:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do we know that is the file you received, and not the one you edited last night?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the answer is "trust me", you have already lost. However right you are on the substance.&lt;/p&gt;

&lt;p&gt;This problem is not exclusive to a courtroom. The auditor receiving a log dump has it. So does the team documenting an incident, or anyone keeping a copy of a contract signed over email. In every case the need is the same: being able to prove that a set of bytes has not changed since a given moment — and having that proved by &lt;strong&gt;someone who is not you&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is why I wrote &lt;a href="https://github.com/isazajuancarlos/tunjo" rel="noopener noreferrer"&gt;Tunjo&lt;/a&gt;: a Rust tool that walks material read-only, computes its fingerprint, and signs a record anyone can verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a tree and not a hash
&lt;/h2&gt;

&lt;p&gt;The obvious approach would be to concatenate everything and take one SHA-256. It works, and it is useless in practice.&lt;/p&gt;

&lt;p&gt;When someone disputes &lt;strong&gt;one&lt;/strong&gt; file — a specific email out of four thousand — a single hash leaves you two options: hand over the complete set so it can be recomputed, or ask to be believed. The first exposes material that has no business being exposed; the second is not evidence.&lt;/p&gt;

&lt;p&gt;A Merkle tree solves exactly that. Each file is a leaf, each pair of nodes combines upward, and a root remains. To prove a leaf belongs to that root, you only need to show that leaf and the path of hashes to the top: a few kilobytes. The rest of the set is never touched.&lt;/p&gt;

&lt;p&gt;Two details of the tree that are not optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Domain separation: a leaf can never pass itself off as an internal node.&lt;/span&gt;
&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="nf"&gt;.update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;0x00&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;          &lt;span class="c1"&gt;// leaf&lt;/span&gt;
&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="nf"&gt;.update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;0x01&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;          &lt;span class="c1"&gt;// internal node&lt;/span&gt;

&lt;span class="c1"&gt;// And the root binds the number of leaves.&lt;/span&gt;
&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="nf"&gt;.update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;0x02&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="nf"&gt;.update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="nf"&gt;.to_be_bytes&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the first, a leaf hash could be presented as if it were a node of the tree. Without the second you get the classic ambiguity of trees with an odd number of leaves: two &lt;strong&gt;different&lt;/strong&gt; sets can produce the same root. It is an old, well-known bug, and it keeps showing up in new implementations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fingerprint covers state, not just content
&lt;/h2&gt;

&lt;p&gt;The leaf is not the hash of the file: it is the hash of the &lt;strong&gt;complete element&lt;/strong&gt; — path, size, timestamp, status and content hash.&lt;/p&gt;

&lt;p&gt;The difference matters. If the fingerprint covered only content, moving a file to another folder, renaming it, or replacing it with a link pointing at the same content would leave the root intact. And those three moves change what the set means: where a document was is part of the fact being documented, not a presentation detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signing for ten years from now
&lt;/h2&gt;

&lt;p&gt;The record is signed with the triple-hybrid signature from &lt;a href="https://github.com/isazajuancarlos/quipu" rel="noopener noreferrer"&gt;Quipu&lt;/a&gt;: Ed25519 + ML-DSA-87 (FIPS 204) + SLH-DSA-SHA2-256s (FIPS 205) — and &lt;strong&gt;all three&lt;/strong&gt; must validate.&lt;/p&gt;

&lt;p&gt;This is not algorithm collecting. The useful life of this is not measured in months: a case file can take years to resolve, and the signature has to still verify at the end. The three pieces fail for different reasons — Ed25519 against a quantum computer; ML-DSA for being recent and lattice-based; SLH-DSA only if the hash function breaks — and all three are needed at once for the seal to hold. One falling does not bring down the record.&lt;/p&gt;

&lt;p&gt;The cost is honest: the signature weighs about 34 KB. To seal a set of files, that is noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the signature is not enough
&lt;/h2&gt;

&lt;p&gt;This is the easiest mistake to make when implementing something like this. The signature covers the complete JSON of the record, &lt;strong&gt;including the integrity root&lt;/strong&gt;. If verification stops at checking the signature, you have accepted a root nobody recomputed: someone holding the key could sign a record whose root does not correspond to the elements it lists, and it would pass.&lt;/p&gt;

&lt;p&gt;That is why verification always recomputes the tree, and only then looks at the signature. There is a dedicated test for that exact case: an authentic signature over a lying root must fail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tunjo verificar acta.json &lt;span class="nt"&gt;--origen&lt;/span&gt; ./evidence
&lt;span class="go"&gt;SEAL VALID
  content:  4 elements, 3 with verifiable content
  root:     d9f6f68f591c6af087838dc27049a4194ab70525c350b79ec22446f9c12f9e33

1 DISCREPANCY(IES) against evidence:

ALTERED    attachments/c.pdf
           record: 3fdbaf9c795e22f14e16974c37b62ed381b9c8c4ac7bcbe1a01f13d08ec46643
           disk:   9af5d94042eafbf2c335aa874085b263ff0201aee5e5033fd4c432e92de0093d
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When a fact is missing, make noise
&lt;/h2&gt;

&lt;p&gt;An integrity tool that hides its own failures is worse than not having one, because it produces confidence without backing. Three decisions on that:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An unreadable file stops the sealing.&lt;/strong&gt; It is not skipped silently. If it really is unreadable, you have to ask for that explicitly, and then the record logs it as an error: of that element it is recorded that it existed and that reading failed, and nothing more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;About the clock, the truth is told.&lt;/strong&gt; The record requires declaring how it was checked against an external source. If nothing is declared, it writes "NOT VERIFIED" rather than staying quiet. Without a third-party timestamp this proves relative order, not a certain date — and it says that too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symbolic links are not followed.&lt;/strong&gt; Where they point is recorded. Following them would take the acquisition outside the material that was received.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately does not do
&lt;/h2&gt;

&lt;p&gt;It does not detect intrusions, does not attribute authorship, and concludes nothing. I could add heuristics saying "there was an attack here", and it would be a mistake: whoever signs a report has to be able to defend every statement line by line, and nobody defends a heuristic they did not write. When that statement falls, it drags the rest of the report with it.&lt;/p&gt;

&lt;p&gt;Nor does it prove the past. It attests from the instant of acquisition: if the material arrived already altered, the seal faithfully certifies altered material. That is written in the record it generates, not in the fine print.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verifier is public, and not out of generosity
&lt;/h2&gt;

&lt;p&gt;If the only person who can check a seal is the one who issued it, it is not evidence — it is a claim in technical formatting. That is why the verifier is free software and its code is published.&lt;/p&gt;

&lt;p&gt;I checked it the only way that counts: I cloned the public repository on a clean machine, built it from scratch, and used &lt;strong&gt;that&lt;/strong&gt; binary to verify a record sealed elsewhere. Valid. Then I altered one byte of an attachment and the same binary flagged that file and only that one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/isazajuancarlos/tunjo
&lt;span class="nb"&gt;cd &lt;/span&gt;tunjo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cargo build &lt;span class="nt"&gt;--release&lt;/span&gt;
./target/release/tunjo verificar acta.json &lt;span class="nt"&gt;--origen&lt;/span&gt; ./evidence
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rust, AGPL-3.0, and the tests include a simulation of 240 comparisons: one byte is altered in each of 120 files, and it is required to flag that one and only that one, and to leave no false positives once restored. Detecting is easy; discriminating is the work.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>rust</category>
      <category>security</category>
      <category>postquantum</category>
    </item>
    <item>
      <title>They steal your database and cannot crack a single password</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Mon, 31 Aug 2026 18:15:58 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/they-steal-your-database-and-cannot-crack-a-single-password-11ah</link>
      <guid>https://dev.to/isazajuancarlos/they-steal-your-database-and-cannot-crack-a-single-password-11ah</guid>
      <description>&lt;p&gt;Picture the worst Monday: someone walked off with a dump of your users table. Emails, names, and the password column.&lt;/p&gt;

&lt;p&gt;The good news is that you do not store passwords in the clear — you store hashes, with &lt;a href="https://www.rfc-editor.org/rfc/rfc9106.html" rel="noopener noreferrer"&gt;Argon2id&lt;/a&gt;. The bad news is that this matters &lt;strong&gt;less than it seems&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the hash alone is not enough
&lt;/h2&gt;

&lt;p&gt;Argon2id makes every attempt cost memory and time. It is a real defence and you should use it. But look at what holds it up: making &lt;strong&gt;each&lt;/strong&gt; attacker attempt expensive.&lt;/p&gt;

&lt;p&gt;And the attacker holding your database plays by different rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;They are not in a hurry.&lt;/strong&gt; No rate limit, no account lockout, no logs to give them away. They can take months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They do not guess randomly.&lt;/strong&gt; They start with leaked password lists, which cover an uncomfortable fraction of any real user base.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They do not use your server.&lt;/strong&gt; They use GPUs rented by the hour.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Raising Argon2's cost helps, but you pay it on every legitimate login and they pay it once per candidate. The arithmetic is not on your side: if a password sits in the first ten thousand entries of a leaked list, no reasonable parameter saves it.&lt;/p&gt;

&lt;p&gt;The underlying problem is that &lt;strong&gt;the attacker has everything they need&lt;/strong&gt;. The hash, the salt and the parameters are all in the same dump. They can compute the whole function on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: make a piece missing
&lt;/h2&gt;

&lt;p&gt;What if computing the hash depended on a secret that is &lt;strong&gt;not in the database&lt;/strong&gt;?&lt;/p&gt;

&lt;p&gt;The simple version is a &lt;em&gt;pepper&lt;/em&gt;: a key in the application config that gets mixed with the password before hashing. It helps, and it has two serious problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It usually leaks along with the database.&lt;/strong&gt; If the attacker got into the server, the environment variable was one &lt;code&gt;cat&lt;/code&gt; away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It cannot be rotated.&lt;/strong&gt; Changing the pepper invalidates every hash at once.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An OPRF solves the first one at the root: the secret &lt;strong&gt;lives in another service&lt;/strong&gt;, and the elegant part is that this service &lt;strong&gt;never sees the password&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an OPRF is, without the maths
&lt;/h2&gt;

&lt;p&gt;An &lt;em&gt;Oblivious Pseudo-Random Function&lt;/em&gt; is a computation between two parties with an unusual property:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You send your password &lt;strong&gt;blinded&lt;/strong&gt; — mathematically masked. The server receives noise.&lt;/li&gt;
&lt;li&gt;The server applies its secret key &lt;code&gt;k&lt;/code&gt; to that noise and returns more noise.&lt;/li&gt;
&lt;li&gt;You &lt;em&gt;unblind&lt;/em&gt; it and get a value that &lt;strong&gt;can only be obtained with that key &lt;code&gt;k&lt;/code&gt;&lt;/strong&gt; and that password.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The server does not learn your password. You do not learn its key. And without talking to it, &lt;strong&gt;nobody can compute that value&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That value is what you hash with Argon2id and store.&lt;/p&gt;

&lt;p&gt;Now the attacker holding your database has the hash, the salt, the parameters… and is missing &lt;code&gt;k&lt;/code&gt;. They cannot test a single candidate offline. To attack, they have to talk to the OPRF service once per attempt — and there you do have rate limits, quotas and logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They have turned an unlimited offline attack into a measurable online one.&lt;/strong&gt; That is the whole trick, and it is enormous.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part almost nobody implements: the "verifiable"
&lt;/h2&gt;

&lt;p&gt;There is a catch. If the OPRF server is compromised, or simply uses the wrong key, it returns garbage values and &lt;strong&gt;you never find out&lt;/strong&gt;: you hash garbage, store it, and lock out all your users. Worse, someone controlling the server could answer with a key they know.&lt;/p&gt;

&lt;p&gt;That is why the variant that matters is the &lt;strong&gt;verifiable&lt;/strong&gt; one (VOPRF, &lt;a href="https://www.rfc-editor.org/rfc/rfc9497.html" rel="noopener noreferrer"&gt;RFC 9497&lt;/a&gt;): the server attaches a &lt;strong&gt;DLEQ proof&lt;/strong&gt; that it used the correct key, and the client verifies it against a public key it has pinned.&lt;/p&gt;

&lt;p&gt;And here is the detail that makes half the integrations you will find out there decorative: &lt;strong&gt;that public key has to be pinned out of band&lt;/strong&gt;. If your client asks for it from the same server it is about to verify, you have verified nothing — you asked the examinee to bring their own answer key.&lt;/p&gt;

&lt;p&gt;It is an easy mistake to make. I made it: two of my own clients would fetch the key from the server if the integrator omitted it. It was fixed by making it mandatory.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks, said before it happens to you
&lt;/h2&gt;

&lt;p&gt;No architectural decision is free, and this one has three real costs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Your logins depend on another service.&lt;/strong&gt; If the OPRF does not answer, you cannot verify passwords. And here the only correct answer is to &lt;strong&gt;fail closed&lt;/strong&gt;: if the service is down or the proof does not validate, the login fails. The temptation to "degrade to unhardened" turns an outage into a bypass — and an attacker who can take the service down skips the whole protection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The key &lt;code&gt;k&lt;/code&gt; never rotates.&lt;/strong&gt; Rotating it invalidates every secret hardened with it. It is a once-and-for-all decision and must be treated as one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It adds latency&lt;/strong&gt; to login: one network round trip. Measurable, small, but not zero.&lt;/p&gt;

&lt;p&gt;If your application cannot accept point 1, this is not for you. I would rather say so here than afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it is used in Django
&lt;/h2&gt;

&lt;p&gt;I wrote the primitives in Rust following RFC 9497 and verified them against the official Appendix A.1.2 vectors, then packaged them for Python. The Django integration is a hasher that plugs in where yours already sits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;quipu-oprf-django
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;&lt;span class="n"&gt;PASSWORD_HASHERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quipu_oprf_django.hashers.OprfArgon2PasswordHasher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# preferred
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;django.contrib.auth.hashers.Argon2PasswordHasher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# migration
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;django.contrib.auth.hashers.PBKDF2PasswordHasher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;QUIPU_OPRF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;QUIPU_OPRF_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;QUIPU_OPRF_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="c1"&gt;# 64 hex, PINNED OUT OF BAND. Do not ask the server for it.
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PUBLIC_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;QUIPU_OPRF_PUBKEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TIMEOUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Migration is gradual: existing users keep logging in with their old hasher and get hardened the next time they sign in.&lt;/p&gt;

&lt;p&gt;The package is &lt;strong&gt;Apache-2.0&lt;/strong&gt; on purpose. It lives inside your authentication server, and putting a network copyleft licence in there would be indefensible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-host it or pay for it
&lt;/h2&gt;

&lt;p&gt;The server is open source (&lt;a href="https://github.com/isazajuancarlos/quipu" rel="noopener noreferrer"&gt;AGPL&lt;/a&gt;) and you can run it yourself: it is one binary with SQLite and needs nothing else. If you do that, you pay me nothing, and that is perfectly fine.&lt;/p&gt;

&lt;p&gt;What is charged for at &lt;a href="https://xiliux.com/quipu/precios" rel="noopener noreferrer"&gt;&lt;code&gt;oprf.xiliux.com&lt;/code&gt;&lt;/a&gt; is &lt;strong&gt;not having to operate it&lt;/strong&gt;: availability, quota, and making sure the key &lt;code&gt;k&lt;/code&gt; survives your deployments. Because the day you lose that key, you lose every password at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;It is not magic and it replaces nothing you should already be doing: keep using Argon2id with serious parameters, keep your second factor, keep limiting attempts.&lt;/p&gt;

&lt;p&gt;What it does is take away the most profitable attack an adversary has against a stolen database. That is not nothing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The code is at &lt;a href="https://github.com/isazajuancarlos/quipu" rel="noopener noreferrer"&gt;github.com/isazajuancarlos/quipu&lt;/a&gt;. The VOPRF primitives are Apache-2.0 and conform to RFC 9497. The project does not yet have an independent cryptographic audit, and that has to be said too.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>rust</category>
      <category>security</category>
      <category>postquantum</category>
    </item>
    <item>
      <title>Hybrid encryption: why combine classical and post-quantum cryptography</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Sun, 30 Aug 2026 18:16:42 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/hybrid-encryption-why-combine-classical-and-post-quantum-cryptography-3hkp</link>
      <guid>https://dev.to/isazajuancarlos/hybrid-encryption-why-combine-classical-and-post-quantum-cryptography-3hkp</guid>
      <description>&lt;p&gt;When a new cryptographic algorithm appears, a tension shows up: &lt;strong&gt;classical&lt;/strong&gt; algorithms such as X25519 or Ed25519 have resisted attacks for years, but are vulnerable to a future quantum computer; &lt;strong&gt;post-quantum&lt;/strong&gt; ones such as ML-KEM or ML-DSA resist quantum attacks, but are newer and less tested. Hybrid encryption resolves the tension: &lt;strong&gt;use both at once&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea in one sentence
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Combine a classical and a post-quantum algorithm so that the system &lt;strong&gt;only breaks if both fail simultaneously&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A classical attacker would have to break the post-quantum algorithm; a quantum attacker would have to break the classical one &lt;em&gt;and&lt;/em&gt; the post-quantum one. You gain security against the future without betting everything on a young algorithm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two places to apply it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Key exchange (encrypting for a recipient).&lt;/strong&gt; You combine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;X25519&lt;/strong&gt; — classical key exchange, fast and heavily tested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ML-KEM-1024&lt;/strong&gt; — NIST's post-quantum key encapsulation mechanism, at its highest level.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two resulting keys are mixed with a context-bound derivation function (HKDF), so that neither one alone is enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Digital signatures (authenticity).&lt;/strong&gt; You combine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ed25519&lt;/strong&gt; — classical signature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ML-DSA-87&lt;/strong&gt; — NIST post-quantum signature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The message is accepted only if &lt;strong&gt;both&lt;/strong&gt; signatures verify — an AND combiner.&lt;/p&gt;

&lt;h2&gt;
  
  
  One principle that never breaks
&lt;/h2&gt;

&lt;p&gt;There is a golden rule in cryptography, &lt;strong&gt;Kerckhoffs's principle&lt;/strong&gt;: a system must be secure even if the attacker knows its entire design; security lives in the &lt;strong&gt;key&lt;/strong&gt;, not in hiding the format. A good hybrid system uses public, audited primitives — XChaCha20-Poly1305 to encrypt, Argon2id to derive keys from passwords, HKDF to separate domains — and &lt;strong&gt;never invents its own cryptography&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Quipu applies it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://xiliux.com/blog/quipu-cifrado-multilenguaje-rust-python-node-go" rel="noopener noreferrer"&gt;Quipu&lt;/a&gt; is a free library implementing exactly this approach for &lt;strong&gt;data at rest&lt;/strong&gt;: hybrid X25519 + ML-KEM-1024 encryption, hybrid Ed25519 + ML-DSA-87 signatures, and only verified primitives underneath. It targets NIST security level 5 (CNSA 2.0) and is open source, so anyone can review how it works.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An honest note: Quipu composes standard primitives, but the composition has not yet passed an &lt;strong&gt;independent&lt;/strong&gt; cryptographic audit. For real high-value secrets, that external review is the seal worth waiting for.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/isazajuancarlos/quipu" rel="noopener noreferrer"&gt;github.com/isazajuancarlos/quipu&lt;/a&gt;, AGPL-3.0.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>rust</category>
      <category>security</category>
      <category>postquantum</category>
    </item>
    <item>
      <title>Quipu: post-quantum encryption in pure Rust, with a Python wheel</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Sat, 29 Aug 2026 18:37:43 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/quipu-post-quantum-encryption-in-pure-rust-with-a-python-wheel-3lm</link>
      <guid>https://dev.to/isazajuancarlos/quipu-post-quantum-encryption-in-pure-rust-with-a-python-wheel-3lm</guid>
      <description>&lt;p&gt;Protecting data that must stay secret ten years from now is a &lt;strong&gt;problem for today&lt;/strong&gt;: an adversary can capture your encrypted traffic now and decrypt it once quantum capability exists (&lt;em&gt;harvest now, decrypt later&lt;/em&gt;). &lt;a href="https://xiliux.com/blog/que-es-criptografia-post-cuantica" rel="noopener noreferrer"&gt;Quipu&lt;/a&gt; is a free &lt;strong&gt;hybrid post-quantum&lt;/strong&gt; encryption library for data at rest: it combines proven classical cryptography with the new kind, so that it only breaks if &lt;strong&gt;both&lt;/strong&gt; fall at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pure Rust, and why
&lt;/h2&gt;

&lt;p&gt;Quipu started out aiming at several languages: a Rust core with a &lt;strong&gt;C ABI&lt;/strong&gt; on top and bindings for Python, Node and Go. It worked, but the lesson was clear: maintaining a stable C interface plus four bindings, each with its own packaging and interoperability tests, was &lt;strong&gt;complexity that did not pay for itself&lt;/strong&gt; against the real goal — protecting data at rest — and it widened the attack surface with &lt;code&gt;unsafe&lt;/code&gt; we did not want.&lt;/p&gt;

&lt;p&gt;Today Quipu is &lt;strong&gt;pure Rust&lt;/strong&gt;: memory safe, no garbage collector, &lt;strong&gt;no first-party &lt;code&gt;unsafe&lt;/code&gt;&lt;/strong&gt;. And for people who do not write Rust, it ships as a &lt;strong&gt;native Python wheel&lt;/strong&gt; via PyO3 — the surface that non-Rust users actually need. One codebase, one thing to audit. It is the same philosophy that guides the rest: where good cryptography exists, reuse it; simplicity is a security decision, not a convenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo add quipu              &lt;span class="c"&gt;# Rust&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;quipu-crypto     &lt;span class="c"&gt;# Python (native wheel, PyO3)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Encrypt and decrypt in Python
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;

&lt;span class="c1"&gt;# Symmetric, with a passphrase
&lt;/span&gt;&lt;span class="n"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sensitive data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-passphrase&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrypt_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-passphrase&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sensitive data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Post-quantum, for a recipient
&lt;/span&gt;&lt;span class="n"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_keypair&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# X25519 + ML-KEM-1024
&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode_to_recipient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode_as_recipient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What is underneath
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encryption:&lt;/strong&gt; XChaCha20-Poly1305 (authenticated AEAD).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key derivation:&lt;/strong&gt; Argon2id (brute-force resistant) + HKDF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post-quantum:&lt;/strong&gt; X25519 + ML-KEM-1024 for keys; Ed25519 + ML-DSA-87 for signatures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security level:&lt;/strong&gt; NIST category 5 (CNSA 2.0).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are all &lt;strong&gt;standard, verified primitives&lt;/strong&gt; — the ML-KEM-1024 and ML-DSA-87 known-answer tests run against NIST's official ACVP vectors. Quipu &lt;strong&gt;composes&lt;/strong&gt; them; it does not invent its own cryptography.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free and transparent
&lt;/h2&gt;

&lt;p&gt;Quipu is &lt;strong&gt;open source&lt;/strong&gt; (AGPL-3.0). You can read every line and audit the format, which is specified byte by byte. It is published on &lt;a href="https://crates.io/crates/quipu" rel="noopener noreferrer"&gt;crates.io&lt;/a&gt; and, as a Python wheel, on &lt;a href="https://pypi.org/project/quipu-crypto/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Honest status: the composition has not yet passed an &lt;strong&gt;independent cryptographic audit&lt;/strong&gt;. Until that external seal exists, treat it as software for review and experimentation, not for protecting real high-value secrets.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/isazajuancarlos/quipu" rel="noopener noreferrer"&gt;github.com/isazajuancarlos/quipu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>rust</category>
      <category>security</category>
      <category>postquantum</category>
    </item>
    <item>
      <title>What is post-quantum cryptography, and why migrate now?</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Sat, 29 Aug 2026 14:07:24 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/what-is-post-quantum-cryptography-and-why-migrate-now-34ci</link>
      <guid>https://dev.to/isazajuancarlos/what-is-post-quantum-cryptography-and-why-migrate-now-34ci</guid>
      <description>&lt;p&gt;The cryptography protecting your data today — keys, HTTPS, signatures — rests on mathematical problems that current computers cannot solve in any reasonable time. A large enough quantum computer &lt;strong&gt;could&lt;/strong&gt; solve some of them. That is what post-quantum cryptography is: algorithms designed to resist both classical and quantum computers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threat is not only in the future
&lt;/h2&gt;

&lt;p&gt;You might think: "no quantum computer can break my encryption yet, so I can wait." The problem is an attack called &lt;strong&gt;harvest now, decrypt later&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An adversary &lt;strong&gt;captures your encrypted traffic today&lt;/strong&gt; and stores it. Once quantum capability exists years from now, they &lt;strong&gt;decrypt it&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your data must stay secret ten years from now — medical records, trade secrets, personal data — the threat is &lt;strong&gt;present&lt;/strong&gt;, not future. What you encrypt today with classical cryptography could be read tomorrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new NIST standards
&lt;/h2&gt;

&lt;p&gt;NIST, the US standards body, finalised the first post-quantum algorithms in 2024 after years of public analysis:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ML-KEM&lt;/strong&gt; (formerly CRYSTALS-Kyber): for &lt;strong&gt;key exchange&lt;/strong&gt; — how two parties agree on a shared secret securely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ML-DSA&lt;/strong&gt; (formerly CRYSTALS-Dilithium): for &lt;strong&gt;digital signatures&lt;/strong&gt; — how to verify a message is authentic and unaltered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are no longer academic experiments. They are standards that governments and large companies are starting to require.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hybrid encryption: the prudent approach
&lt;/h2&gt;

&lt;p&gt;Post-quantum algorithms are new, and "new" in cryptography means "less battle-tested". That is why current best practice is &lt;strong&gt;hybrid&lt;/strong&gt;: combine a well-established classical algorithm with a post-quantum one. The result only breaks if &lt;strong&gt;both&lt;/strong&gt; fall at once. You gain post-quantum protection without giving up decades of confidence in classical cryptography.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory&lt;/strong&gt; where you use cryptography and which data must last years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prioritise&lt;/strong&gt; long-lived data at rest — the most exposed to "harvest now".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adopt hybrid encryption&lt;/strong&gt; rather than wholesale replacement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use libraries that compose verified primitives&lt;/strong&gt;, not home-made cryptography.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point is the key one, and it is the philosophy behind &lt;a href="https://xiliux.com/blog/quipu-cifrado-multilenguaje-rust-python-node-go" rel="noopener noreferrer"&gt;Quipu&lt;/a&gt;, a free hybrid post-quantum encryption library for data at rest.&lt;/p&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/isazajuancarlos/quipu" rel="noopener noreferrer"&gt;github.com/isazajuancarlos/quipu&lt;/a&gt;. It is AGPL-3.0, and the composition has not yet passed an independent cryptographic audit — which is worth saying out loud.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>rust</category>
      <category>security</category>
      <category>postquantum</category>
    </item>
    <item>
      <title>Quipu: cifrado post-cuántico en Rust puro, con una rueda para Python</title>
      <dc:creator>Juan Carlos Isaza</dc:creator>
      <pubDate>Mon, 24 Aug 2026 18:38:41 +0000</pubDate>
      <link>https://dev.to/isazajuancarlos/quipu-cifrado-post-cuantico-en-rust-puro-con-una-rueda-para-python-1194</link>
      <guid>https://dev.to/isazajuancarlos/quipu-cifrado-post-cuantico-en-rust-puro-con-una-rueda-para-python-1194</guid>
      <description>&lt;p&gt;Proteger datos que deben seguir siendo secretos dentro de diez años es un problema &lt;strong&gt;de hoy&lt;/strong&gt;: un adversario puede capturar tu tráfico cifrado ahora y descifrarlo cuando exista la capacidad cuántica (&lt;em&gt;harvest now, decrypt later&lt;/em&gt;). &lt;a href="https://dev.to/blog/que-es-criptografia-post-cuantica"&gt;Quipu&lt;/a&gt; es una librería libre de cifrado &lt;strong&gt;híbrido post-cuántico&lt;/strong&gt; para datos en reposo: combina criptografía clásica probada con la nueva, de modo que solo se rompe si &lt;strong&gt;ambas&lt;/strong&gt; caen a la vez.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rust puro, y por qué
&lt;/h2&gt;

&lt;p&gt;Quipu nació apuntando a varios lenguajes: un núcleo en Rust con una &lt;strong&gt;C ABI&lt;/strong&gt; encima y bindings para Python, Node y Go. Funcionaba, pero la lección fue clara: mantener una interfaz de C estable más cuatro bindings, cada uno con su empaquetado y sus pruebas de interoperabilidad, era &lt;strong&gt;complejidad que no pagaba&lt;/strong&gt; para el objetivo real —proteger datos en reposo— y ampliaba la superficie de ataque con &lt;code&gt;unsafe&lt;/code&gt; que no queríamos.&lt;/p&gt;

&lt;p&gt;Hoy Quipu es &lt;strong&gt;Rust puro&lt;/strong&gt;: memoria segura, sin &lt;em&gt;garbage collector&lt;/em&gt;, &lt;strong&gt;sin &lt;code&gt;unsafe&lt;/code&gt; de primera parte&lt;/strong&gt;. Y para quien no programa en Rust, se distribuye como &lt;strong&gt;rueda nativa de Python&lt;/strong&gt; (vía PyO3) — que es la superficie que el cliente que no es de Rust de verdad necesita. Una sola base de código, una sola cosa que auditar. Es la misma filosofía que guía el resto: donde hay buena criptografía, se reutiliza; la simplicidad es una decisión de seguridad, no una comodidad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instalación
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo add quipu              &lt;span class="c"&gt;# Rust&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;quipu-crypto     &lt;span class="c"&gt;# Python (rueda nativa, PyO3)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cifrar y descifrar en Python
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;

&lt;span class="c1"&gt;# Simétrico con contraseña
&lt;/span&gt;&lt;span class="n"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datos sensibles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mi-passphrase&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrypt_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mi-passphrase&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datos sensibles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Post-cuántico para un destinatario
&lt;/span&gt;&lt;span class="n"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_keypair&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# X25519 + ML-KEM-1024
&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode_to_recipient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secreto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;quipu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode_as_recipient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secreto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Qué hay debajo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cifrado:&lt;/strong&gt; XChaCha20-Poly1305 (AEAD autenticado).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derivación de claves:&lt;/strong&gt; Argon2id (resistente a fuerza bruta) + HKDF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post-cuántico:&lt;/strong&gt; X25519 + ML-KEM-1024 para claves; Ed25519 + ML-DSA-87 para firmas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nivel de seguridad:&lt;/strong&gt; categoría NIST 5 (CNSA 2.0).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Todo son primitivas &lt;strong&gt;verificadas y estándar&lt;/strong&gt; —los KAT de ML-KEM-1024 y ML-DSA-87 se comprueban contra los vectores oficiales del NIST (ACVP)—: Quipu las &lt;strong&gt;compone&lt;/strong&gt;, no inventa criptografía propia.&lt;/p&gt;

&lt;h2&gt;
  
  
  Libre y transparente
&lt;/h2&gt;

&lt;p&gt;Quipu es &lt;strong&gt;código abierto&lt;/strong&gt; (AGPL-3.0). Puedes leer cada línea y auditar el formato, especificado byte a byte. Está publicado en &lt;a href="https://crates.io/crates/quipu" rel="noopener noreferrer"&gt;crates.io&lt;/a&gt; y, como rueda de Python, en &lt;a href="https://pypi.org/project/quipu-crypto/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Estado honesto: la composición aún no ha pasado una &lt;strong&gt;auditoría criptográfica independiente&lt;/strong&gt;; hasta ese sello externo, trátalo como software para revisión y experimentación, no para proteger secretos reales de alto valor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Si necesitas proteger datos en reposo con visión post-cuántica y un formato auditable, &lt;a href="https://dev.to/#contacto"&gt;hablemos&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>rust</category>
      <category>security</category>
      <category>postquantum</category>
    </item>
  </channel>
</rss>
