DEV Community

tomcate
tomcate

Posted on

The Checksum That Knows It's Prime: Reverse-Engineering China's USCC Validation

If your backend touches Chinese business data, it stores 18-character Unified Social Credit Codes. Most English-language validation code I've seen for them is quietly wrong — it accepts impossible characters, or borrows the wrong checksum entirely. The fix takes one screen of code, but understanding why the algorithm is built the way it is makes the bugs obvious. This is that walkthrough; every claim is verifiable in a browser tab, no registry access needed.

The alphabet is the anti-confusion layer

The code draws from exactly 31 characters: digits 0-9 plus A-Z minus five letters — I, O, S, V, Z. Every exclusion kills a visual confusion pair (I/1, O/0, S/5, V/U, Z/2).

This means the cheapest validation step isn't the checksum — it's the character set. A regex like [A-Z0-9]{18} accepts codes that can never exist. And the exclusions cut one way: an O in your data is always a 0 that something "corrected". An I is always a 1. Blanket-stripping vowels is wrong, though — U, W, Y are legal.

The checksum: powers of 3, folded mod 31

Each of the first 17 characters maps to its index in that 31-character alphabet. Multiply by fixed weights, sum, fold mod 31, reflect:

weights = [1, 3, 9, 27, 19, 26, 16, 17, 20, 29, 25, 13, 8, 24, 10, 30, 28]

check_index = (31 - (Σ index(cᵢ) × weightᵢ) mod 31) mod 31
Enter fullscreen mode Exit fullscreen mode

The detail almost nobody mentions: those weights are 3⁰, 3¹, 3², … mod 31. Check: 1, 3, 9, 27, then 3⁴=81 mod 31 = 19, 3⁵=243 mod 31 = 26, 3⁶=729 mod 31 = 16 — the table matches, position by position.

Why powers of 3? Because 31 is prime and 3 is a primitive root mod 31 — the powers of 3 cycle through all 30 non-zero residues. A weight sequence that hits every residue once is what lets the checksum catch every single-character substitution (each position gets a unique multiplier) and most transpositions. If you ported a validator that hardcoded the table without knowing why, an off-by-one in the table lets roughly 1 in 31 invalid codes slip through — quiet enough to survive any spot check.

Two bugs that survive code review

Both are real, both come from open-source validators I tested while building ours:

Bug 1: weights in character-value space. The table must be indexed by alphabet position (1-based powers of 3), not by the character's numeric value. Mixing the two spaces passes ~3% of garbage and fails ~3% of valid codes — invisible until a real customer's code bounces.

Bug 2: the outer mod. (31 - (sum % 31)) % 31 — drop the outer % 31 and a sum divisible by 31 maps to index 31, one past the end of the alphabet. Depending on your language, that's an undefined character or an out-of-range crash. The code that survives review is the code that was never tested with sum % 31 == 0.

Worked example, verifiable by hand

Body: 91110000MA01AB9C7 (a Beijing enterprise shape: department 9, category 1, authority 110000).

  • First four positions: 9×1 + 1×3 + 1×9 + 1×27 = 48
  • Full sum through position 17: 1761
  • 1761 mod 31 = 25; 31 − 25 = 6; alphabet index 6 is 6
  • Valid code: 91110000MA01AB9C76

Flip one legal character — MA01 → MB01 — and the correct check character becomes 8. The checksum did its job; don't "repair" it by recomputing the 18th character, route it to manual review instead.

What validation can't tell you

A checksum-valid USCC is well-formed, nothing more. Registry existence, entity status, and name matching live with the national publicity system — a lookup this site deliberately doesn't perform. The browser-local USCC validator checks format only, which is also why pasting codes into it leaks nothing. For fixtures, the test-data generator produces checksum-valid synthetic codes — random bodies, correct check character, never a claim of registration.

The full field guide (all five corruption modes, NFKC full-width traps, FAQ) lives at wcytcn.com/blog/china-uscc-validation-guide.

Implementation notes

The whole thing is a 31-char constant, a 17-entry weight table, and one accumulator loop — ours ships with round-trip tests (generate N valid codes, validate all, tamper each, assert rejection). The two bugs above are exactly the kind that a generate-and-tamper loop catches deterministically while eyeballing the table never will.

Top comments (0)