DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our activation code bans I and O, and the prefix in front of it is NTFIO

Notifio is a desktop app that watches rental search pages and tells you about new listings. It is sold as a one-time licence rather than a subscription, so there are no accounts, no passwords and no sign-in on the website at all. After checkout the server emails an activation code, and you type your email plus that code into the app on first launch.

That code is the entire credential. Here is all of the code that makes one:

import crypto from "crypto";

/**
 * Generates a human-readable activation token: NTFIO-XXXX-XXXX-XXXX
 * where X is an uppercase alphanumeric character (no ambiguous chars like O/0, I/1).
 */
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

function randomSegment(length: number): string {
  const bytes = crypto.randomBytes(length);
  return Array.from(bytes)
    .map((b) => ALPHABET[b % ALPHABET.length])
    .join("");
}

export function generateToken(): string {
  return `NTFIO-${randomSegment(4)}-${randomSegment(4)}-${randomSegment(4)}`;
}
Enter fullscreen mode Exit fullscreen mode

Twenty characters, twelve of them random. Three decisions are hiding in there, and only one of them is written down.

The alphabet is a transcription decision

The constant is 24 letters and 8 digits. Compared with the full 36-character uppercase alphanumeric set it is missing exactly four: I, O, 0, 1. Those are the pairs that break when a string stops being data and becomes something a person retypes.

And this string does become that. It arrives in an email with the subject "Your Notifio activation code", and from there it goes into a text field in a desktop app, usually by paste but not always, because people read their mail on a phone and activate on a laptop. It also ends up quoted back to me in support mail, which is why the help page has an entry for losing it rather than a password reset flow. There is nothing to reset.

So the shape is deliberate: grouped in fours, hyphen separated, no character that looks like another character.

Then there is the prefix. NTFIO contains both an I and an O, two of the four characters the alphabet took out. That is fine, because the prefix is constant: it carries zero bits and it exists so that a string in an inbox is recognisably ours. Nobody has to decide whether that O is a zero, because it is always an O. It is still funny that the banned characters show up in the first five bytes of every token we issue.

32 is also the reason the modulo is honest

bytes[i] % ALPHABET.length is the classic biased-random shape. The usual version of this bug looks exactly like the line above and quietly skews the output, because 256 does not divide evenly by most alphabet lengths.

Here it does. ALPHABET.length is 32, 256 / 32 is 8, so each of the 32 characters is produced by exactly 8 of the 256 possible byte values. The draw is uniform. Not because the mapping is careful, but because of the length of a string constant.

Change the constant and the property disappears without a sound:

Alphabet size Bytes per character Result
32 8, 8, 8, ... uniform
31 9 for eight characters, 8 for twenty-three those eight are 12.5% more likely
26 10 for twenty-two, 9 for four those twenty-two are 11% more likely
36 8 for four characters, 7 for thirty-two those four are 14% more likely

Nothing in the repository asserts this. If a customer ever tells me they mistook 2 for Z and I drop the 2, the alphabet becomes 31 characters, every token still looks perfectly fine, no test fails, and the entropy quietly stops being what I think it is. For a twelve-character token the practical consequence is small. The fact that I cannot tell by reading the file is the actual problem.

The honest fix is one line next to the constant:

// Uniform only while 256 is a multiple of the alphabet length.
if (256 % ALPHABET.length !== 0) {
  throw new Error(`Token alphabet of ${ALPHABET.length} would bias randomSegment`);
}
Enter fullscreen mode Exit fullscreen mode

The alternative is rejection sampling, which removes the constraint entirely at the cost of a loop. For a function that runs once per purchase I would rather keep the loop out and keep the invariant visible.

While we are counting: 32 is 2 to the 5th, so each character is worth exactly 5 bits and twelve of them are worth 60. That is about 1.15 x 10^18 possible codes, against an endpoint that is rate limited before it reaches the database. Online guessing is not the threat model. Transcription is.

It is checked as a pair, and the failure says nothing

The code alone is not the credential. Both of the endpoints the desktop app talks to want the email too:

export async function resolveLicense(
  email: string,
  token: string
): Promise<License | null> {
  if (!email || !token) return null;

  const [license] = await db
    .select()
    .from(licenses)
    .where(eq(licenses.token, token))
    .limit(1);

  if (!license || license.email !== email || !license.active) return null;
  return license;
}
Enter fullscreen mode Exit fullscreen mode

One query, by the token, which is one of the unique columns on our single table. Then three ways to get null back: no such token, the email does not match, the licence is not active. The caller cannot tell which, because the return type has no room to say. A validation endpoint that distinguishes "wrong code" from "right code, wrong email" is an oracle, and there is no product reason to build one.

The matching normalisation sits at the boundary, in both routes that accept these two fields:

const body = await req.json();
email = (body.email ?? "").trim().toLowerCase();
token = (body.token ?? "").trim().toUpperCase();
Enter fullscreen mode Exit fullscreen mode

Trim, because copying out of an email client brings a trailing space along more often than you would think. Lowercase the email and uppercase the token, because the generator only ever emits uppercase and a person retyping does not know that. Doing this in the route rather than in resolveLicense is a small wart: it means every future caller has to remember. It is two lines in two files today, so it stays, and resolveLicense keeps the one job it has.

The last property is that the code is stable. A Stripe webhook can fire more than once for a single payment, and the token has to come out the same every time, or a retry emails a customer a second code and invalidates the first one they already typed in.

Sixty bits in a plain text file

On the user's machine the code lands in config.json under the app's writable data directory, in plain text, next to the saved searches. It is a bearer credential and the app is a local Electron app, so it cannot keep a secret from its owner anyway.

What makes that acceptable is the blast radius rather than the storage. The code authorises exactly two things: asking whether the licence is valid, and asking the server to send an alert email. The second one is the interesting half, and the address it mails has to equal the one stored on the licence row, so a request cannot redirect the alerts anywhere. So a stolen code buys someone a boolean and the ability to mail the victim their own rental alerts.

That is the trade I would most want a reader to take away. The amount of ceremony a credential deserves is set by what it unlocks, and if the answer is "a boolean and an email to its owner", then 60 bits, a readable alphabet and a plain text file are proportionate. What is not proportionate is leaving a uniformity property resting on a string literal without saying so in the file.

You can see the whole path from the outside: buy on the pricing page, the code arrives by email, and you type it into the app from the download page along with the address you bought with. The lost-code answer and the rest of the setup questions are on the help page, and the per-site setup guides are at notifio.app/alerts, including Kamernet and Rightmove.

Top comments (0)