Violet
Blog

Build log 002: the gates found real holes

By The Violet build · published 2026-08-17 · updated 2026-08-17 · 7 minute read

Build log 002 covers 2026-07-04 to 2026-08-17. The site went live at heyviolet.io with a double opt-in waitlist, a referral engine, a browser demo of the real sealing crypto, and three free privacy tools. Adversarial review gates found three real security holes in our own shipped code, a membership timing oracle, a durable-token regression, and an unauthenticated-metadata replay path in the sync envelope, and one attack session against the recovery kit that failed to break it. Violet herself is still pre-launch: no customers, no revenue.

This is the second Violet build log. It covers one repository between 2026-07-04, the date of build log 001, and 2026-08-17. The rule is unchanged: every sentence below is checkable against the code and the git history, including the sentences that do not flatter us. This post has more of those than usual, because the period's most important work was adversarial review finding real holes in code we had already called done.

What shipped

Build log 001 ended with nothing deployed. That changed the same week: the site went live at heyviolet.io with the domain cutover and launch polish, and the waitlist became a real deployed Cloudflare Worker (apps/marketing/src/worker.mjs) storing signups in D1 with double opt-in email through Resend. Since then, in commit order:

The gates, and why we run them

Every substantial change in this period went through an adversarial gate before it counted as done: a separate review pass whose only job is to break the change, with the findings written down and fixed before merge. A gate that never finds anything is theater. Ours found three real holes this period, all in code that had already passed its own tests. That is uncomfortable and it is also the point: the process only proves anything when it catches us.

Finding one: the membership timing oracle

The waitlist join endpoint is designed to be neutral: a new email and an already-listed email both get the identical {ok:true} response, so nobody can use the form to test whether an address is on the list. The gate found that the response body was neutral but the response time was not. For a brand-new signup the handler awaited the confirmation-email send inline, a full HTTPS round trip to the email provider, while a duplicate returned after database work alone. Measure the latency and you learn membership without reading a byte of the body.

The fix defers the send past the response with the Workers waitUntil primitive, so both paths answer on the same schedule and the send still happens. The test suite keeps a deterministic awaited path, and the neutrality test now pins the response contract byte for byte. The uncomfortable part, stated plainly: the inline await had been live since the waitlist first deployed on 2026-07-08, so this subtle hole sat in production for about six weeks before the gate caught it on 2026-08-17. Six weeks of a hole our own tests were happy with is exactly why the gates exist.

Finding two: the durable-token regression we shipped ourselves

The referral engine (2026-08-16) turned the email confirmation page into the referral hub, and to make the emailed link a bookmarkable door back to that hub, it kept the confirm token valid after use. That was a regression we wrote, reviewed, and shipped: a single-use credential quietly became a permanent one that lived in inbox history, forwarded emails, and anything with mailbox access.

The gate flagged it the next day. The fix restores the single-use contract, clearing the token in the same atomic update that flips the row to confirmed, and moves the durable door to a hub keyed by the referral code, which reveals exactly what the public status endpoint already reveals for that code: position and referral count, never the email. The gate also caught a race in the same area, where a legacy row could briefly render a share code that was never stored. Both fixes are pinned by tests: token nulled on the flip, replay gets the neutral page, hub renders for confirmed codes only.

Finding three: the envelope authenticated the secret but not the metadata

The serious one. Violet's sync protocol seals each record into a {nonce, ciphertext} envelope with AES-256-GCM. GCM authenticates the ciphertext, and it can also authenticate additional data that travels in the clear, the AAD. Our seal call passed empty AAD. The consequence: the merge metadata alongside each envelope, its version number and delete state, was not authenticated at all. A malicious or compromised server could take a genuine envelope a device had produced and replay it under a forged version to roll a record back, or worse, resurrect a record the user had deleted. The ciphertext would decrypt cleanly, because the ciphertext was never the problem.

The fix binds the record's identity and lifecycle into the AAD: collection, record id, version, updated_at, and the deleted flag are now authenticated inputs to every seal and open (apps/cloud/src/protocol.mjs and the client's mirror of it). A replayed envelope under forged metadata now simply fails to open, and the failure is closed. Two new protocol tests pin the property: version rollback, delete-state flips, and id or collection swaps all reject. This is exactly the class of attack AAD exists for; NIST SP 800-38D is the primary source on what GCM does and does not authenticate.

One honest note on exposure: this hole never touched a user, because sync ships gated off and Violet is pre-launch with no users. It was still a real hole in real shipped code, found by an adversarial pass after the feature had passed its own tests.

The session that failed to break the recovery kit

The recovery kit answers the hardest question in our FAQ, what happens if you lose your passphrase, without giving the server a way in. The design: a generated 160-bit recovery code, never user-chosen, shown once and downloadable on the device. It derives a recovery key through the same labeled PBKDF2 path as the main key, under its own domain label, and seals a recovery envelope for the passphrase with the same AAD binding as finding three. The envelope is stored as an ordinary opaque sync record the server cannot distinguish or open.

The gate ran a dedicated attack session against it: replay attacks, rollback attacks, oracle probes, and weak-entropy attempts. Each one dies. A wrong code fails closed with a uniform result, so there is no oracle to iterate against. Deleting the kit writes a real tombstone. A no-leak test proves the wire-body whitelist drops injected secrets. We are publishing the attack list precisely because a reader with a security background should be able to ask whether we tried the obvious attacks. We did, and the ones we tried are named in the commit.

The numbers as of this post

$ node --test apps/cloud/test/*.test.mjs      # tests 91, pass 91
$ node --test apps/companion/test/*.test.mjs  # tests 49, pass 49
$ node --test apps/marketing/test/*.test.mjs  # tests 21, pass 21

The waitlist count is deliberately not printed here, because a number in a dated post goes stale the moment it is published. It lives on the open page, read live from the production database, whatever it is that day.

What is still not true

The standard from build log 001 holds: anyone can diff this log against the repository, and the next log can be measured against this one. Publishing the holes our own gates found is the strongest version of that standard we know how to write. If honesty is the architecture inside the product, it has to survive contact with our own security findings too.

Questions

Why publish your own security failures?

Because the alternative is a security process nobody can verify. Anyone can claim their code is reviewed; a dated list of what the review actually caught, in shipped code, with the fixes and their tests named, is checkable. A review process that never finds anything is indistinguishable from no process at all.

Did any of these holes affect real users?

No, with one honest caveat. The sync envelope finding could not reach users because sync ships gated off and Violet is pre-launch with no users. The two waitlist findings concerned a live endpoint: the timing oracle was in production for about six weeks and the token regression for one day. At worst they could reveal whether an email address was on the list or extend the life of an emailed link; neither exposed addresses, positions, or content. We have no evidence either was exploited, and no way to fully rule it out, which is the honest answer for any timing oracle.

What is an adversarial gate?

A review pass, run after a change passes its own tests, whose only job is to break the change: probe the endpoints, replay the protocol messages, forge the metadata, time the responses. Findings are written down and must be fixed, with a pinning test, before the change counts as done.

How can I check these claims myself?

Every claim maps to the repository: the waitlist worker and its neutrality tests in apps/marketing, the sync protocol and its AAD tests in apps/cloud/src/protocol.mjs and its test suite, the recovery kit in the companion app, and the commit history carries the dated record. The live waitlist count is on the open page at /open.

Related pages

Sources