身份证号怎么测?18位、日期和校验码,不能混成一个“合法”
身份证号怎么测?18位、日期和校验码,不能混成一个“合法”
旧题单里有个问题:“使用等价类与边界值的方法,对身份证号写测试用例。”
第一步很容易想到长度:17位、18位、19位。再补空字符串、字母、特殊字符,似乎已经列出不少用例了。
但先问一句:拿到18个符合要求的字符以后,我们到底验证了什么?
本文只讨论18位公民身份号码的几个离线检查层次,不处理15位旧格式,也不做真实身份核验。所有主案例统一用人为设置的000000前缀,它不是本文认可的有效地区代码;不使用真实个人数据,不把这些字符串拿去实际网站注册。
先把18个位置分开
按照GB 11643—1999的结构规定,这18个位置不是同一类东西:前面有地址、日期和顺序码,最后一位根据前17位计算。末位还可能是X,所以“必须18个数字”本身就不准确。
我们先拿下面这个合成字符串做实验。为了看位置,图里加了空格,实际输入没有空格:
000000 20000229 001 0 前缀 日期 顺序 校验 6位 8位 3位 1位检查长度和字符,只能确认这些位置里放得下相应字符。它不会自动知道2月有多少天,更不会知道这个号码有没有实际分配给某个人。
先把程序的返回值拆开:
shape: 长度与字符符合本例要求吗 date: 日期码在公历中存在吗 checksum: 最后一位与计算结果一致吗这三个检查点先别急着合成一个isValid。等一会儿测试失败时,我们还要靠它们判断是哪层规则在起作用。
本例的输入策略是:只收字符串,不自动去掉空格;前17位只收ASCII数字,末位允许数字或X,输入小写x时按大写比较。小写和空格怎么处理,应该先与需求约定,不能仅凭程序写起来方便就替产品决定。
日期不存在,校验码也可能完全正确
现在只改图里的年份,把20000229改成20010229。
2000年有2月29日,2001年没有。这个差别已经不属于字符串长度了,两个日期码都是8位数字。
可是还不能马上拿新字符串去测日期。刚才改了前17位,最后的校验字符可能也该变。如果仍然沿用旧的末位,我们可能同时弄错了日期和校验码。
为了只观察日期检查,需要给这个“不存在的日期”重新算一个匹配的校验字符:
A:000000 20000229 001 0 日期存在,校验匹配 B:000000 20010229 001 8 日期不存在,校验仍匹配运行后,B的结果是:
shape: true date: false checksum: true校验码算对了,根本不妨碍前面的日期是假的。校验计算并不查询日历,它只是按字符做运算。
这样构造样本还有个用处:如果日期检查被人删了,B就能把问题暴露出来。假如B的校验码也错了,程序可能仍然拒绝B,让我们误以为日期检查没问题。
所以“非法样本被拒绝”还不够。我们得知道,它是不是被想测试的那条规则拒绝的。
校验码怎么跟着前面的字符变化?
先不管字符串表达什么,把前17个数字按位置乘上权重,再求和。权重和余数映射来自标准的校验公式与表格,完整实现放在后面。
对于A,前六位都是0,可以先忽略那些乘积。剩下几个非零乘积如下:
20000229 001 对应的非零乘积: 2×2 + 2×9 + 2×10 + 9×5 + 1×2 = 4 + 18 + 20 + 45 + 2 = 89 89 % 11 = 1 余数1,对应校验字符0从2000改到2001时,年份最后一位从0变成1,它对应的权重是3。因此总和从89变成92,余数从1变成4,对应字符从0变成8。
这里的变化完全可以算出来,却没有一步在问“2001年是不是闰年”。这就是B能够同时满足“日期不存在”和“校验匹配”的原因。
反过来,如果要测校验字符错误,就保持日期不变,只把A最后的0换成1:
C:000000 20000229 001 1 shape: true date: true checksum: false现在B和C各自承担一个明确的检查目标,不是一堆不知道错在哪里的随机字符串。
边界值不只在总长度上
回头补用例,就有方向了。
长度可以测17、18、19位;日期要看它自己的边界,例如4月30日与4月31日,2月最后一天,以及1900年与2000年的闰年差别。不能把年份能被4整除就一律当闰年。
这些日期用例也都由测试生成匹配的校验字符,让日期层单独承担判断。
字符还要看位置:末位的X与前17位出现X,是两种不同输入。一个前端允许粘贴的全角数字,视觉上像数字,但不在本例约定的ASCII集合里。
还有输入类型。标识符不是拿来加减的数量:传入JavaScript的Number之前,前导零可能已经消失;较大的整数还存在精确表示问题。本文直接拒绝非字符串,不做悄悄的类型转换。
这样再去说等价类,会更具体:同样是“不能通过”,长度不对、字符位置不对、日期不存在、校验不匹配,分别需要不同的代表样本。边界值则围绕各层真实的临界条件选,不是对每个数字都机械加一减一。
三层都过了,为什么还不叫“身份验证成功”?
再看A。我们的函数会返回三个true,但前缀000000根本没有经过地区代码验证。
甚至把日期换成2099年的一个日历上存在的日期,重新配好校验字符,这个函数也会放行。因为它检查的是“公历日期存在”,没有承诺“出生日期不能晚于今天”,更没有检查业务年龄范围。
所以返回值叫offlineChecksPassed,不是“身份证有效”;还明确带着:
identityVerified: false这不是再加一个字段就让程序变安全了,而是在提醒调用者别把检查结果读过头。地址代码及其历史适用范围、顺序码分配、具体业务日期规则,都不在这三个检查里面;号码是否真实、是否属于提交者,也不能从一个离线校验公式推出。
如果需求真要验证这些,就要分别补对应的数据和流程,不能把这个函数改个名字便当作已经实现。
完整程序与验证
下面两个文件使用Node.js,实验版本为22.14.0。不依赖第三方包,也不访问网络。
id-layers.mjs:
const WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; const CHECKS = '10X98765432'; export function checkCharacter(body) { if (typeof body !== 'string' || body.length !== 17 || !/^[0-9]{17}$/.test(body)) { throw new TypeError('Expected 17 ASCII digits'); } let sum = 0; for (let i = 0; i < body.length; i++) sum += Number(body[i]) * WEIGHTS[i]; return CHECKS[sum % 11]; } function calendarDate(code) { const year = Number(code.slice(0, 4)); const month = Number(code.slice(4, 6)); const day = Number(code.slice(6, 8)); if (year < 1 || month < 1 || month > 12) return false; const leap = year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0); const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; return day >= 1 && day <= days[month - 1]; } export function inspect18(input) { const result = { shape: false, date: null, checksum: null, offlineChecksPassed: false, identityVerified: false }; if (typeof input !== 'string') return { ...result, reason: 'type' }; if (input.length !== 18 || !/^[0-9]{17}[0-9Xx]$/.test(input)) { return { ...result, reason: 'shape' }; } const date = calendarDate(input.slice(6, 14)); const checksum = checkCharacter(input.slice(0, 17)) === input[17].toUpperCase(); return { shape: true, date, checksum, offlineChecksPassed: date && checksum, identityVerified: false, reason: !date ? 'date' : !checksum ? 'checksum' : 'offline_only' }; }日期用明确的月天数和闰年判断,不借助会自动归一化日期的构造过程。形状未通过时,日期和校验字符返回null,表示尚未执行,而不是把“没检查”写成“检查失败”。
id-layers.test.mjs:
import test from 'node:test'; import assert from 'node:assert/strict'; import { checkCharacter, inspect18 } from './id-layers.mjs'; // Independent route: the complete 18-character weighted sum must be 1 modulo 11. function oracle(body) { for (const last of '0123456789X') { const text = body + last; let sum = 0n; for (let i = 0; i < 18; i++) { const value = text[i] === 'X' ? 10n : BigInt(text[i]); sum += value * (2n ** BigInt(17 - i) % 11n); } if (sum % 11n === 1n) return last; } throw new Error('No check character'); } // 000000 is only a synthetic test prefix, not a claim of a valid address code. function sample(date, sequence = '001') { const body = '000000' + date + sequence; return body + oracle(body); } test('synthetic baseline has the hand-calculated check character', () => { const code = sample('20000229'); assert.equal(code, '000000200002290010'); assert.deepEqual(inspect18(code), { shape: true, date: true, checksum: true, offlineChecksPassed: true, identityVerified: false, reason: 'offline_only' }); }); test('shape lengths are 17, 18 and 19', () => { const code = sample('20000229'); assert.equal(inspect18(code.slice(0, 17)).reason, 'shape'); assert.equal(inspect18(code).shape, true); assert.equal(inspect18(code + '0').reason, 'shape'); }); test('number values are not silently converted into identifier strings', () => { assert.equal(inspect18(Number(sample('20000229'))).reason, 'type'); }); test('spaces, newline and fullwidth digits are not accepted', () => { const code = sample('20000229'); for (const s of [' ' + code, code + ' ', code + '\n', '0' + code.slice(1)]) { assert.equal(inspect18(s).reason, 'shape'); } }); test('an impossible date can still have a matching checksum', () => { const r = inspect18(sample('20010229')); assert.deepEqual([r.shape, r.date, r.checksum, r.reason], [true, false, true, 'date']); }); test('calendar boundaries include leap centuries and month ends', () => { for (const [date, valid] of [['19000229', false], ['20000229', true], ['20000430', true], ['20000431', false], ['20000001', false], ['20001301', false], ['20000100', false], ['00000101', false]]) { assert.equal(inspect18(sample(date)).date, valid, date); } }); test('wrong checksum is isolated from a valid date and shape', () => { const r = inspect18(sample('20000229').slice(0, 17) + '1'); assert.deepEqual([r.shape, r.date, r.checksum, r.reason], [true, true, false, 'checksum']); }); test('this input policy accepts lowercase x only in the last position', () => { const x = Array.from({length: 100}, (_, i) => sample('20000229', String(i).padStart(3, '0'))).find(s => s.endsWith('X')); assert.ok(x);assert.equal(inspect18(x.slice(0, 17) + 'x').offlineChecksPassed, true); assert.equal(inspect18('x' + x.slice(1)).reason, 'shape'); }); test('offline checks do not certify an address, age or actual identity', () => { const r = inspect18(sample('20991231')); assert.equal(r.offlineChecksPassed, true); assert.equal(r.identityVerified, false); }); test('all positions and every remainder agree with an independent formula', () => { const remainders = new Set(); for (let i = 0; i < 17; i++) for (let digit = 0; digit < 10; digit++) { const body = '0'.repeat(i) + digit + '0'.repeat(16 - i); assert.equal(checkCharacter(body), oracle(body), `position ${i}, digit ${digit}`); remainders.add(Number(BigInt(digit) * (2n ** BigInt(17 - i) % 11n) % 11n)); } assert.equal(remainders.size, 11); });运行:
node --test id-layers.test.mjs测试生成校验字符时,没有调用被测的checkCharacter。它走另一条计算路径:从整串18个字符的加权和模11等于1出发,找满足条件的末位;权重按幂公式计算,不复制实现里的权重数组和映射字符串。这样能降低“生成器和检查器写着同一个错误,互相证明正确”的风险。
主案例前六位全是0,会让这些位置的权重错误不容易露出来。因此还补了逐位置的单数字变化,覆盖全部位置和11种余数。这些只是校验函数的数学输入,并不是合法身份号码样本。
最后又故意改错几份程序:去掉日期检查、把1900年当闰年、写错余数映射、改坏一个权重,分别确认原有用例会失败。这里只说明这些具体错误能被发现,不代表完整身份编码规则已经测完。
参考:标准信息页、官方公开标准文本。本文只实现其中一部分可离线检查的条件,不声称完成整份标准或真实身份核验。