", burned} * 注:serial 字段仍是 u32 整数;sn 串里的序号为 base36 定宽 6 位(与固件一致)。 * 失败 {"ok":false, "error":"..."} * * 验签消息布局(必须与固件 app_device_sn.c 逐字节一致,共 22 字节): * m = "PENID1"(6) ‖ MAC(6) * ‖ fw_major(u8) ‖ fw_minor(u8) ‖ fw_patch(u8) * ‖ build_yy(u8) ‖ build_mm(u8) ‖ build_dd(u8) * ‖ serial(u32 LE) * sig = ECDSA-P256( SHA256(m) ),转成 64 字节裸 r‖s。 */ header('Content-Type: application/json; charset=utf-8'); date_default_timezone_set('Asia/Shanghai'); // 台账时间戳用北京时间(+08:00) require __DIR__ . '/key.php'; /* 台账库选择:?env=test → 独立测试台账 sn_production.test.json,不污染生产台账。 * 白名单只认 'test',其余一律落生产库,杜绝路径穿越。两库共用同一签名 key, * 测试机照样拿到合法签名,只是序号/计数各自独立。 */ $ENV = (isset($_GET['env']) && $_GET['env'] === 'test') ? 'test' : 'prod'; $STORE_FILE = __DIR__ . ($ENV === 'test' ? '/sn_production.test.json' : '/sn_production.json'); function fail($msg, $code = 400) { http_response_code($code); echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE); exit; } /* 固件版本 -> 系列 key(按版本分桶,序号换版本从 1 起)。 */ function version_str($maj, $min, $pat) { return sprintf('%d.%d.%d', $maj, $min, $pat); } function series_key($maj, $min, $pat) { return 'V' . version_str($maj, $min, $pat); } function build_str($yy, $mm, $dd) { return sprintf('%02d%02d%02d', $yy, $mm, $dd); } /* 序号渲染:u32 -> 定宽 base36(0-9A-Z, 高位补0, 高位在前)。必须与固件 * app_device_sn.c::sn_serial_b36() 逐字符一致(SN_SERIAL_B36_WIDTH=6)。 * 底层 serial 仍是 u32 整数(签名 pack('V',serial) 不变), 仅人类可读串改 base36。 * 容量 36^6≈2.18e9, 超过则高位回绕(实际 per-version 序号远不及此)。 */ function serial_b36($serial) { $d = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; $v = (int)$serial; $out = ''; for ($i = 0; $i < 6; $i++) { $out = $d[$v % 36] . $out; $v = intdiv($v, 36); } return $out; } /* 身份段 SN 尾串(与固件 UNCONFIG-V… 一致,去掉前缀):V--。 */ function sn_string($maj, $min, $pat, $yy, $mm, $dd, $serial) { return sprintf('V%s-%s-%s', version_str($maj, $min, $pat), build_str($yy, $mm, $dd), serial_b36($serial)); } /* DER ECDSA 签名 -> 64 字节裸 r‖s(PSA / 固件期望格式) */ function der_to_raw($der) { $o = 0; if (ord($der[$o++]) !== 0x30) return false; // SEQUENCE $seqlen = ord($der[$o++]); if ($seqlen & 0x80) { $o += ($seqlen & 0x7f); } // 长度长格式则跳过长度字节 if (ord($der[$o++]) !== 0x02) return false; // INTEGER r $rlen = ord($der[$o++]); $r = substr($der, $o, $rlen); $o += $rlen; if (ord($der[$o++]) !== 0x02) return false; // INTEGER s $slen = ord($der[$o++]); $s = substr($der, $o, $slen); $r = ltrim($r, "\x00"); $s = ltrim($s, "\x00"); if (strlen($r) > 32 || strlen($s) > 32) return false; $r = str_pad($r, 32, "\x00", STR_PAD_LEFT); $s = str_pad($s, 32, "\x00", STR_PAD_LEFT); return $r . $s; } /* 构造 22 字节待签消息 */ function build_msg($mac6, $maj, $min, $pat, $yy, $mm, $dd, $serial) { $m = 'PENID1'; $m .= $mac6; $m .= chr($maj & 0xFF); $m .= chr($min & 0xFF); $m .= chr($pat & 0xFF); $m .= chr($yy & 0xFF); $m .= chr($mm & 0xFF); $m .= chr($dd & 0xFF); $m .= pack('V', $serial); // u32 LE return $m; } /* ---- 解析请求 ---- */ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { fail('POST only', 405); } $raw = file_get_contents('php://input'); $req = json_decode($raw, true); if (!is_array($req)) { fail('bad json body'); } $mac_hex = strtoupper(trim($req['mac'] ?? '')); $maj = (int)($req['fw_major'] ?? -1); $min = (int)($req['fw_minor'] ?? -1); $pat = (int)($req['fw_patch'] ?? -1); $yy = (int)($req['build_yy'] ?? -1); $mm = (int)($req['build_mm'] ?? -1); $dd = (int)($req['build_dd'] ?? -1); if (!preg_match('/^[0-9A-F]{12}$/', $mac_hex)) fail('bad mac (need 12 hex)'); if ($maj < 0 || $maj > 255 || $min < 0 || $min > 255 || $pat < 0 || $pat > 255) fail('bad fw version'); if ($yy < 0 || $yy > 99 || $mm < 1 || $mm > 12 || $dd < 1 || $dd > 31) fail('bad build date'); $mac6 = hex2bin($mac_hex); /* ---- 独占读写 store ---- */ $fp = fopen($STORE_FILE, 'c+'); if (!$fp) fail('cannot open store', 500); if (!flock($fp, LOCK_EX)) { fclose($fp); fail('cannot lock store', 500); } $content = stream_get_contents($fp); $store = $content !== '' ? json_decode($content, true) : null; if (!is_array($store)) $store = ['series' => []]; if (!isset($store['series']) || !is_array($store['series'])) $store['series'] = []; $key = series_key($maj, $min, $pat); $release = function () use ($fp) { flock($fp, LOCK_UN); fclose($fp); }; /* 系列不存在则自动建(永远发券,不要求预授权)。 */ if (!isset($store['series'][$key]) || !is_array($store['series'][$key])) { $store['series'][$key] = [ 'fw_version' => version_str($maj, $min, $pat), 'burned' => 0, 'devices' => [], ]; } $series = $store['series'][$key]; /* 幂等:同 MAC 在本版本已发过 -> 返回原券,不重复计数 */ foreach (($series['devices'] ?? []) as $d) { if (($d['mac'] ?? '') === $mac_hex) { $release(); echo json_encode([ 'ok' => true, 'idempotent' => true, 'fw_version' => version_str($maj, $min, $pat), 'build_date' => build_str($yy, $mm, $dd), 'serial' => (int)$d['serial'], 'sn' => $d['sn'], 'sig' => $d['sig'], 'burned' => (int)($series['burned'] ?? 0), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } } /* 分配序号(该版本内递增,换版本即新系列从 1 起) */ $burned = (int)($series['burned'] ?? 0); $serial = $burned + 1; /* 签名 */ $pkey = openssl_pkey_get_private(sn_private_key_pem()); if (!$pkey) { $release(); fail('server key load failed', 500); } $msg = build_msg($mac6, $maj, $min, $pat, $yy, $mm, $dd, $serial); $der = ''; if (!openssl_sign($msg, $der, $pkey, OPENSSL_ALGO_SHA256)) { $release(); fail('sign failed', 500); } $rawsig = der_to_raw($der); if ($rawsig === false || strlen($rawsig) !== 64) { $release(); fail('sig convert failed', 500); } $sig_b64 = base64_encode($rawsig); $sn = sn_string($maj, $min, $pat, $yy, $mm, $dd, $serial); /* 记账 */ $store['series'][$key]['burned'] = $serial; $store['series'][$key]['devices'][] = [ 'serial' => $serial, 'sn' => $sn, 'mac' => $mac_hex, 'build' => build_str($yy, $mm, $dd), 'sig' => $sig_b64, 'time' => date('c'), ]; $store['updated_at'] = date('c'); /* 原地覆盖写回(持锁中) */ $out = json_encode($store, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); rewind($fp); ftruncate($fp, 0); fwrite($fp, $out); fflush($fp); $release(); echo json_encode([ 'ok' => true, 'fw_version' => version_str($maj, $min, $pat), 'build_date' => build_str($yy, $mm, $dd), 'serial' => $serial, 'sn' => $sn, 'sig' => $sig_b64, 'burned' => $serial, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);