Designing a license key humans can type: base32, segment grouping, and a check digit that catches typos
A Keyright license is signed bytes, but the thing a customer reads off an invoice, types into an activation dialog, or dictates over the phone is a string — and base64 is a terrible string for that job. It is case-sensitive, it uses + and / which get mangled in email and shells, and a single mistyped character produces a signature failure with no hint of where the mistake was. This post builds the transport encoding Keyright actually uses: Crockford base32 so the alphabet survives a phone call and a shift key, five-character segments so the eye can track position, and a mod-37 check symbol that catches every single-character error and almost every transposition before you ever run the expensive signature verification. It shows the real encode and decode in C#, the normalization that turns a sloppily typed key back into canonical bytes, and why the check digit is an ergonomics feature and never a security one.
A Keyright license is, at bottom, a signed blob of bytes: a canonical payload — edition, entitlements, node-lock, expiry — plus an RSA signature over it. The cryptography is settled elsewhere. This post is about the part that touches a human: the string that customer reads off an invoice, pastes into an activation box, or reads to your support engineer over a bad phone line. That string is not the license. It is a transport encoding of the license bytes, and the encoding you pick decides whether a customer activates on the first try or opens a ticket.
The reflexive choice is base64, because that is what Convert.ToBase64String gives you for free. It is the wrong choice for anything a person handles.
// The signed license bytes — payload + RSA signature.
byte[] license = SignLicense(payload, privateKey); // ~128+ bytes
// The tempting, wrong transport encoding:
string key = Convert.ToBase64String(license);
// "Tw4bQ8K2...e+/9Zg==" — case-sensitive, has + and /, no grouping, no typo check
Everything about that output fights the human who has to type it. Base64 is case-sensitive, so a customer who hits the wrong shift state gets a hard failure. It contains +, / and =, which email clients, URL encoders and shells love to mangle. It has no structure, so a 180-character run is impossible to track by eye or dictate without losing your place. And when one character is wrong, the only feedback is a signature failure at the very end — no hint of where the mistake was. We can do far better, and the pieces are well understood.
Pick an alphabet that survives a phone call
The first decision is the alphabet. The constraints are human: it must be case-insensitive (nobody tracks case over the phone), and it must not contain characters people confuse — 1/I/l, 0/O, U/V. Douglas Crockford’s base32 alphabet is built for exactly this. It encodes five bits per symbol over 32 characters, dropping I, L, O and U entirely.
// Crockford base32: 32 symbols, no I, L, O, U.
private const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
Five bits per character is slightly less dense than base64’s six, but density is not the goal — transcribability is. The payoff comes at decode time, where the same design lets you forgive the predictable mistakes: an O a customer typed becomes 0, an I or L becomes 1, and lowercase folds to upper before anything else happens.
Encode the bytes
Encoding is a bit-pump: accumulate incoming bits in a buffer and emit one symbol every time you have five. This handles any byte length without padding tables.
public static string EncodeBase32(byte[] data)
{
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
int buffer = 0, bitsInBuffer = 0;
foreach (byte b in data)
{
buffer = (buffer << 8) | b;
bitsInBuffer += 8;
while (bitsInBuffer >= 5)
{
bitsInBuffer -= 5;
int index = (buffer >> bitsInBuffer) & 0x1F;
sb.Append(Alphabet[index]);
}
}
if (bitsInBuffer > 0) // flush the final partial group
{
int index = (buffer << (5 - bitsInBuffer)) & 0x1F;
sb.Append(Alphabet[index]);
}
return sb.ToString();
}
The check symbol: mod 37
Now the part that earns its keep. Treat the encoded symbols as the digits of a large number in base 32, take that number modulo 37, and append one symbol that encodes the remainder. Thirty-seven is chosen because it is prime and larger than the radix, which is what gives the check symbol its error-detecting power: it catches every single-symbol substitution, and the overwhelming majority of adjacent transpositions — precisely the two mistakes a person typing makes.
You do not need a big-integer type to compute the modulus. A running fold computes n mod 37 exactly, one symbol at a time, because (a·32 + b) mod 37 = ((a mod 37)·32 + b) mod 37.
// 37 symbols: the 32 base symbols plus 5 extras for remainders 32..36.
private const string CheckAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U";
public static char ComputeCheckSymbol(string base32)
{
int acc = 0;
foreach (char c in base32)
acc = (acc * 32 + Alphabet.IndexOf(c)) % 37; // exact n mod 37, no BigInteger
return CheckAlphabet[acc];
}
The extended alphabet adds five symbols — *, ~, $, =, U — used only for the check position, so a remainder of 32–36 is still expressible in one character. (U is free to reuse here because it was excluded from the data alphabet.)
Group it, and normalize on the way back in
Grouping is pure presentation: slice the symbols-plus-check into fixed-width runs and join with hyphens. Fixed width is what lets the eye and the ear keep their place — “segment three, second character.”
public static string Format(byte[] license)
{
string body = EncodeBase32(license);
string full = body + ComputeCheckSymbol(body);
var sb = new StringBuilder(full.Length + full.Length / 5);
for (int i = 0; i < full.Length; i++)
{
if (i > 0 && i % 5 == 0) sb.Append('-');
sb.Append(full[i]);
}
return sb.ToString(); // "KETP4-9ZQ2M-7X0R8-WVND5-3"
}
Decoding has to undo a human first. Before anything, normalize: strip whitespace and hyphens, uppercase, and map the confusable characters the alphabet deliberately left out. Only then do you validate the check symbol — and only if that passes do you reconstruct the bytes and hand them to signature verification.
public static bool TryParse(string typed, out byte[] license)
{
license = Array.Empty<byte>();
// 1. Normalize what a human typed back toward the canonical form.
string s = typed.Trim().ToUpperInvariant()
.Replace("-", "").Replace(" ", "")
.Replace('O', '0').Replace('I', '1').Replace('L', '1');
if (s.Length < 2) return false;
// 2. Split body from the trailing check symbol and re-verify it.
string body = s[..^1];
char check = s[^1];
if (ComputeCheckSymbol(body) != check)
return false; // localized: "there is a typo"
// 3. Only now reconstruct bytes. Signature verification happens after this.
license = DecodeBase32(body);
return true;
}
DecodeBase32 is the inverse bit-pump — accumulate five bits per symbol, emit a byte every eight bits — and is omitted here only for length. The important sequencing is in TryParse: normalize, then check symbol, then bytes, then signature. The check symbol gives the customer a precise, instant error for the mistake they actually made; the signature, which runs afterward on the decoded bytes, is the only thing that decides whether the license is genuine.
The line you must not cross
It is worth being blunt about the boundary, because it is the one place this design goes wrong in practice. The check symbol is computed by anyone, over the key itself, with no secret involved. An attacker who edits a key can trivially recompute a valid check symbol for whatever string they produced — so the check symbol proves nothing about authenticity. It is an ergonomics feature: it localizes typos and lets you fail fast with a useful message before paying for public-key crypto.
The division of labor is the whole point. One stage is for the honest customer who fat-fingered a character; the other is for the dishonest one who edited the string. Build both, run them in that order, and read every claim — edition, entitlements, expiry — from the signature-verified bytes, never from the characters themselves. The visible key should stay a flat, meaningless-looking run of segments whose only job is to arrive, intact, from a human’s keyboard to your TryParse.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.