云计算百科
云计算领域专业知识百科平台

身份证号怎么测?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年当闰年、写错余数映射、改坏一个权重,分别确认原有用例会失败。这里只说明这些具体错误能被发现,不代表完整身份编码规则已经测完。

参考:标准信息页、官方公开标准文本。本文只实现其中一部分可离线检查的条件,不声称完成整份标准或真实身份核验。

赞(0)
未经允许不得转载:网硕互联帮助中心 » 身份证号怎么测?18位、日期和校验码,不能混成一个“合法”
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!