<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Davor — personal blog · #security</title><description>Notes from building software at Codify in Zagreb: what I built, what broke, and what I would do differently.</description><link>https://davor.codify.hr/</link><language>en</language><atom:link href="https://davor.codify.hr/tags/security/feed.xml" rel="self" type="application/rss+xml"/><item><title>aesmsg: encrypted links for the apps you already use</title><link>https://davor.codify.hr/posts/aesmsg/</link><guid isPermaLink="true">https://davor.codify.hr/posts/aesmsg/</guid><description>Why we built aesmsg, a zero-knowledge layer that turns a secret into a link you can paste into Slack, WhatsApp or email, and two bugs that only showed up on real phones.</description><pubDate>Sat, 07 Nov 2026 20:30:00 GMT</pubDate><content:encoded>&lt;p&gt;Every team I have worked with sends passwords over chat. An API key goes into a Slack DM, a database password into WhatsApp, a client’s credentials into an email thread that twelve people can search for the next ten years. Everyone knows it is wrong. Nobody wants to install a new messenger to fix it.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://aesmsg.com&quot;&gt;aesmsg&lt;/a&gt; is what we built at Codify to deal with that. It is not a chat app. It turns a message or a file into a link, and you paste that link into whatever channel you were going to use anyway. The channel only ever carries the link. Only the person it was encrypted for can open it.&lt;/p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;You write the message, or attach a file, in the aesmsg app.&lt;/li&gt;
&lt;li&gt;The app encrypts it on your phone, to the recipient’s public key.&lt;/li&gt;
&lt;li&gt;Only the ciphertext and a little metadata go to the server. The server hands back a link.&lt;/li&gt;
&lt;li&gt;You paste the link into Slack, WhatsApp, email, SMS, anything.&lt;/li&gt;
&lt;li&gt;The recipient taps it, the app downloads the ciphertext, and their private key decrypts it on their device after Face ID or a fingerprint.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Links expire on a timer (10 minutes, an hour, a day, a week) or after a set number of opens. The sender can revoke a link at any time. Expiry, last open and revoke all delete the ciphertext from the server, not just hide it.&lt;/p&gt;
&lt;p&gt;The server never sees plaintext, private keys, previews or filenames. Filenames and file types are inside the encrypted envelope. What it does store is the link ID, the ciphertext, the expiry, the open limit and an open count. That is the whole database.&lt;/p&gt;
&lt;h2 id=&quot;the-crypto-briefly&quot;&gt;The crypto, briefly&lt;/h2&gt;
&lt;p&gt;Each message is sealed with HPKE (RFC 9180): X25519 for the key exchange, HKDF-SHA256, and AES-256-GCM. We didn’t invent anything here, and that was the point. The link ID, expiry and open limit are bound into the ciphertext as additional authenticated data. A server that tries to serve a ciphertext under a different link, or with a longer expiry, produces something that fails to decrypt.&lt;/p&gt;
&lt;p&gt;Trust in someone’s key comes from comparing a short fingerprint out of band, read aloud or scanned as a QR code. It looks like &lt;code&gt;AM-A91C-22F0-78BB-…&lt;/code&gt;. If the two of you see the same fingerprint, nobody in the middle swapped the key.&lt;/p&gt;
&lt;p&gt;Private keys never leave the device unless you export an encrypted backup. On the phone the key is wrapped with a random device secret that sits in the Secure Enclave or StrongBox behind biometrics. A backup file is wrapped with your passphrase through Argon2id, with settings heavy enough to make guessing expensive.&lt;/p&gt;
&lt;p&gt;There is also a section in our &lt;a href=&quot;https://github.com/codifydoo/aesmsg/blob/main/docs/security-model.md&quot;&gt;security model&lt;/a&gt; titled “No forward secrecy”, and I think it is the most important page in the repo. Messages are sealed to the recipient’s long-lived key. If that key is stolen later, any ciphertext an attacker already copied can be decrypted. Deleting ciphertext on expiry and supporting key rotation shrink that window, but they don’t close it. I would rather say that plainly than write “military-grade” on the landing page.&lt;/p&gt;
&lt;h2 id=&quot;two-bugs-that-only-real-phones-found&quot;&gt;Two bugs that only real phones found&lt;/h2&gt;
&lt;p&gt;The crypto core had tests, interop fixtures and a cross-process browser test that seals, links, opens, decrypts and revokes against a real API. The bugs that hurt were not in any of that.&lt;/p&gt;
&lt;h3 id=&quot;android-sent-a-nul-byte&quot;&gt;Android sent a NUL byte&lt;/h3&gt;
&lt;p&gt;The open and revoke endpoints take no request body, and the API rejects any body at all. That keeps a future token or proof from sneaking into the body instead of a header. iPhones worked. Every Android phone got “This does not look like a valid secure message” on every link, whichever platform had sealed it.&lt;/p&gt;
&lt;p&gt;The cause was three layers down. Expo’s &lt;code&gt;fetch&lt;/code&gt; on Android sits on OkHttp, which refuses a POST without a body. When you don’t pass one, the native layer quietly substitutes a single zero byte. The server saw a one-byte body and returned 400 before counting the open. That is how we isolated it: the open count stayed at zero.&lt;/p&gt;
&lt;p&gt;The fix went in twice. The server now treats exactly one NUL byte as empty, which repaired the Android builds already on people’s phones without an app release:&lt;/p&gt;
&lt;figure class=&quot;attachment&quot;&gt;&lt;figcaption&gt;&lt;svg width=&quot;14&quot; height=&quot;14&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.6&quot; stroke-linecap=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;circle cx=&quot;6&quot; cy=&quot;6&quot; r=&quot;3&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;6&quot; cy=&quot;18&quot; r=&quot;3&quot;&gt;&lt;/circle&gt;&lt;path d=&quot;M20 4L8.1 15.9M14.5 14.5L20 20M8.1 8.1L12 12&quot;&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span&gt;ATTACHMENT 1/1 · messages-handler.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre class=&quot;astro-code receipt&quot; style=&quot;background-color:#00000000;color:#1a1a1a; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F4FA3&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#1A1A1A&quot;&gt; isEmptyBody(raw: string): boolean {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F4FA3&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#1A1A1A&quot;&gt; raw.length === 0 || raw === &lt;/span&gt;&lt;span style=&quot;color:#2B6B3A&quot;&gt;&quot;\0&quot;&lt;/span&gt;&lt;span style=&quot;color:#1A1A1A&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1A1A1A&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Anything with real content, even a body that merely starts with a NUL, is still rejected. The app now passes an explicit empty string as the body, which makes OkHttp send a true &lt;code&gt;Content-Length: 0&lt;/code&gt;. The comment above that line ends by asking the next person not to “simplify” it away, because it really does look like dead code.&lt;/p&gt;
&lt;h3 id=&quot;the-links-pointed-at-the-wrong-host&quot;&gt;The links pointed at the wrong host&lt;/h3&gt;
&lt;p&gt;Tapping a link is supposed to open the app directly. iOS and Android decide whether to do that by fetching an association file from the link’s domain: &lt;code&gt;apple-app-site-association&lt;/code&gt; for Apple and &lt;code&gt;assetlinks.json&lt;/code&gt; for Google. Neither will follow a redirect to get it.&lt;/p&gt;
&lt;p&gt;Our links were minted on &lt;code&gt;aesmsg.com&lt;/code&gt;, and the apex redirected everything to &lt;code&gt;www&lt;/code&gt;, the association files included. So the OS could never verify the domain, and every tap went through the browser fallback page first. The fix was to mint links on &lt;code&gt;www.aesmsg.com&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;iOS claims both hosts, because Apple checks each domain separately and an unreachable one costs nothing. Android claims only &lt;code&gt;www&lt;/code&gt;. Before Android 12, verification is all or nothing across every host the app claims, and one bad host would have broken the good one too.&lt;/p&gt;
&lt;p&gt;The same week Apple’s file was also being served as &lt;code&gt;application/octet-stream&lt;/code&gt;, because it has no extension, and a &lt;code&gt;nosniff&lt;/code&gt; header stopped anything from guessing. Apple wants &lt;code&gt;application/json&lt;/code&gt;. That was one line of config.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://apps.apple.com/app/aesmsg/id6775473926&quot;&gt;iOS app&lt;/a&gt; is in the App Store. There is also a browser client at &lt;a href=&quot;https://app.aesmsg.com&quot;&gt;app.aesmsg.com&lt;/a&gt; that does all of its crypto in the page and talks only to the API. The backend is three small services: the API, a worker that purges expired ciphertext, and a static site.&lt;/p&gt;
&lt;p&gt;All of it is open source under Apache 2.0 at &lt;a href=&quot;https://github.com/codifydoo/aesmsg&quot;&gt;github.com/codifydoo/aesmsg&lt;/a&gt;. If you send secrets over chat, and you do, try it. If you find something wrong with the crypto, I want to hear about it more than anything else.&lt;/p&gt;</content:encoded><category>security</category><category>mobile</category><category>aesmsg</category></item></channel></rss>