Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 5 min read

Our licence key is 60 bits in three groups of four, and it is unbiased only because the alphabet has 32 letters

Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. It is a one-time purchase with no accounts, no sessions and no password reset, which means the entire identity system i

Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. It is a one-time purchase with no accounts, no sessions and no password reset, which means the entire identity system is one string that arrives by email and gets typed into the app once.

NTFIO-7K3M-QX9B-TFHD

That is the product. Here is everything behind it.

The generator is fourteen lines

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)}`;
}

The alphabet is 24 letters and 8 digits. I, O, 0 and 1 are all absent, so there is no pair of glyphs in a token that anybody has to tell apart. Twelve random characters from a 32-symbol alphabet is exactly five bits each:

32^12 = 1,152,921,504,606,846,976 = 2^60

Sixty bits. Of the twenty characters a customer reads off the screen, five are the brand, three are hyphens, and twelve carry everything.

The line that is correct by coincidence

b % ALPHABET.length is the oldest bias bug in random-string generation, and it is not a bug here:

256 / 32 = 8 exactly

Every one of the 32 symbols is produced by exactly 8 of the 256 possible byte values, so the distribution is flat. That is a property of the number 32, not of this code. Add one character to the alphabet and it stops being true:

33 symbols: 256 = 7*33 + 25
            25 symbols get 8 byte values, 8 symbols get 7
            the common ones are 14.3% more likely

31 symbols: 256 = 8*31 + 8
            8 symbols get 9 byte values, 23 get 8
            12.5% more likely

Nobody would notice. The tokens would still look random, every test would still pass, and the keyspace would quietly shrink. The dangerous part is that the alphabet is the kind of string people edit: somebody removes Z because it looks like 2 in the app's font, and the generator silently becomes biased in the same commit.

So the alphabet's length is now load-bearing documentation, and it should assert itself at module load:

if (256 % ALPHABET.length !== 0) {
  throw new Error("ALPHABET length must divide 256 or `b % len` is biased");
}

Four lines that make the next edit to that string fail loudly. The alternative, rejection sampling, is more correct in general and solves a problem this alphabet does not have.

While I am in there: the fixed prefix NTFIO contains both I and O, the two letters the random part refuses to use. It is harmless, because a constant prefix carries no information and the app does not check it, but it is funny that the brand gets to break the rule.

What happens if two tokens collide

The column is UNIQUE, so Postgres refuses the second one. The odds, for a 60-bit key:

licences issued    P(any collision)
          1,000    1 in 2.3 trillion
         10,000    1 in 23 billion
        100,000    1 in 231 million
      1,000,000    1 in 2.3 million

A coin flip needs about 1.26 billion licences. This is not a risk I am managing, but it is worth knowing what the code does if it happens, because the insert lives inside a catch written for a different problem:

} catch (err) {
  // Concurrent webhook won the race and inserted first. Fall back to
  // the row that won so we still deliver a valid token.
  const [row] = await db
    .select().from(licenses)
    .where(eq(licenses.email, email)).limit(1);
  if (!row) throw err; // genuine DB error β†’ 500 so Stripe retries
  token = row.token;

The recovery assumes the unique violation was on email, and looks the winner up by email. On a token collision there is no row for that email, so it rethrows, the handler answers 500, and Stripe retries the webhook minutes later. The retry calls generateToken() again and gets a different string, so the customer's licence is created and the collision resolves itself.

That is the correct outcome reached by accident. Nothing in this code knows that a token collision is survivable; it survives because the payment provider has a retry queue and because this handler is careful to fail rather than guess. I would rather make it explicit, and the honest version is three lines: catch the error, check which constraint it names, and generate again in a loop with a cap.

The key that nobody types is twice as long

The licence row's primary key comes from a different file:

/**
 * Generates a URL-safe random ID (similar to cuid2 / nanoid) without any
 * external dependency. 24 characters, lowercase hex.
 */
export function createId(): string {
  return crypto.randomBytes(12).toString("hex");
}

96 bits for the thing that lives in a database column, 60 for the thing a human reads off a screen and types into an app. That asymmetry is the right way round and worth stating, because the usual instinct is to make all identifiers the same shape. Transcription cost only applies to one of them. The other may as well be long.

The way back in

The validation endpoint does the minimum normalisation and nothing else:

email = (body.email ?? "").trim().toLowerCase();
token = (body.token ?? "").trim().toUpperCase();

So lowercase typing works, and trailing spaces from a double-click copy work. Then the lookup, whose comment is the design:

/**
 * Look up a licence by token and verify the email matches.
 * Returns null for any mismatch so callers cannot leak which part was wrong.
 */

One valid: false for a wrong token, a wrong email, a mismatched pair and a deactivated licence. It is the right call for a public endpoint that anybody can post to, and a 429 from the rate limiter in front of it is deliberately not the same answer.

What I do not like is what happens before the request. The app's activation screen knows the format well enough to print it as a placeholder:

placeholder="NTFIO-XXXX-XXXX-XXXX"

and then gates on this:

if (!trimmedToken || trimmedToken.length < 10) {
  setError("Enter your activation token.");

A real token is exactly 20 characters. Anything of 10 or more gets a round trip to our server and comes back as the same opaque refusal a genuinely wrong token gets. A regular expression in the renderer would catch half a pasted token, a token with a letter the alphabet never emits, or the common one, an email address pasted into the wrong box, and say something useful about it without asking the server anything. The server's answer stays as blunt as it is.

The smallest finding and the one I am fixing first

The thing in the app says:

Activation token

The email says, in the subject line, the heading and the FAQ entry about not being able to find it:

Your activation code

Two words for one string, split across the two surfaces a new customer uses in the same five minutes. Sixty bits of entropy, a unique constraint, a retry path through Stripe, and the part most likely to cost somebody ten minutes is that we called it two different things.

Notifio is at notifio.app: the licence terms are at notifio.app/terms, what we store about you is in the privacy notice, the installers are at notifio.app/download, and the per-site setup guides start at notifio.app/alerts.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.