
1. 项目背景与需求拆解1.1 为什么QGC二次开发必须搞定离线授权QGroundControlQGC是目前无人机行业里用得最多的开源地面站没有之一。它基于Qt框架开发跨平台跑得顺溜Windows上飞模拟器、Android上接遥控器、Linux上做无人车调试一套代码全包了。正因为它开源、功能全、社区活跃很多做行业应用的公司和个人开发者都会拿它做二次开发定制UI、接特定的飞控协议、集成载荷控制、做任务规划扩展等等。但这里有一个很实际的商业问题你把QGC改了一版加了自己的业务逻辑、行业功能准备发给客户用怎么防止这套东西被随意复制传播尤其是Android端APK装到任何一台手机上都能跑签个名、换个包名就能改头换面。这时候一套可靠的授权机制就非常关键了。而我这里要聊的“单机离线授权”简单说就是在完全没有网络的环境下通过设备绑定、授权文件、加密校验等手段让App只在被授权的设备上、在授权的有效期内正常运行。这听起来不算复杂真正落地到QGC这种体量的工程里坑比想象中多得多。1.2 授权方案选型为什么放弃在线激活做授权方案第一反应通常是“在线激活”用户装完App输入序列号App联网到服务器验证通过就下发一个token。这套方案对纯互联网产品很合适但用在QGC二次开发这种场景里问题非常明显很多时候无人机作业环境根本就没网野外、灾区、临时搭建的指挥点信号时有时无。你要求客户每次启动都联网验证客户能把你的售后电话打爆。自建授权服务器的成本不低还要考虑高可用、域名备案、HTTPS证书、防DDoS等一堆事。你只是卖一套地面站软件不是运营SaaS这笔账算不过来。回传延迟、服务器偶发故障都有可能让正常用户被误判为盗版这种体验非常劝退。相比之下单机离线授权的好处就很直接首次使用时人工导入一个授权文件之后就全本地校验不需要任何网络交互。授权文件可以做成绑定设备ID的一次性文件也可以在允许的范围内复用灵活度完全控制在开发者手里。1.3 整体技术架构概览先把我最终落地的方案画个整体轮廓后面所有内容都围绕这个架构展开授权签发端开发者的电脑上跑一个小工具Python脚本或Qt桌面程序输入客户提供的设备指纹输出一份加密签名后的授权文件例如license.dat。授权校验端嵌入QGC Android App里的一个C模块LicenseManager在App启动阶段完成设备指纹采集、授权文件解析、签名验证、设备绑定校验、有效期检查五个步骤全部通过才放行主界面。防篡改与抗逆向RSA私钥签名授权文件App内置公钥验签关键校验逻辑分散在QGC的启动链路里再加上Android原生层的代码混淆和字符串加密。这个架构的核心好处是签发和校验完全解耦用户拿到授权文件拷进去就能用没有任何网络依赖。而离线授权的安全性上限取决于你对设备指纹的唯一性、RSA密钥的保护程度、以及校验逻辑抗逆向能力的用心程度。下面我逐步拆开细说。2. 离线授权方案核心设计2.1 设备指纹采集唯一性从哪里来授权文件绑定设备的前提是设备本身能被唯一识别。Android平台上采集设备指纹早年间很简单IMEI一拿一个准但现在这条路基本堵死了Android 6.0API 23开始读取IMEI需要动态申请READ_PHONE_STATE权限很多用户对这个权限非常敏感一拒绝你的授权机制就废了。Android 10API 29开始非系统应用完全无法读取IMEI。通过WiFi MAC地址做识别也不可行了Android 6.0之后接口返回的是固定的02:00:00:00:00:00占位值。我这里采用的方案是组合识别多要素拼接后做哈希。核心采集项包括Android IDSettings.Secure.ANDROID_ID每个设备、每个App签名密钥组合下唯一恢复出厂设置会变化但对授权场景来说够用了。Build.BOARD、Build.BRAND、Build.DEVICE、Build.MODEL、Build.SERIAL硬件与系统信息组合。应用签名哈希通过PackageManager拿到App自己的签名参与指纹计算防止APK被重新打包后复制授权文件。具体做法是把这些信息按固定顺序拼接做一次SHA-256再转成十六进制字符串取前32位作为设备指纹。代码层面用JNI从Java层取数传字符串给C层做哈希这样核心逻辑留在原生层比纯Java实现更难被直接调用和篡改。实测下来指纹在正常使用场景下保持稳定用户刷机、清缓存、换SIM卡都不会导致指纹变化只有恢复出厂设置才可能变。2.2 授权文件结构与RSA签名校验授权文件我定义成了自定义二进制格式而不直接用明文JSON。这么做不是为了搞什么高深技术纯粹是为了降低普通用户随意篡改授权日期、设备ID的可能性。格式设计如下按偏移量说明0-15字节魔数固定为QGC_LICENSE_V1用于快速识别文件类型。16-23字节版本号int64小端序。24-55字节授权客户名称UTF-8字符串定长32字节。56-87字节绑定设备指纹定长32字节十六进制字符串。88-95字节授权生效时间戳int64。96-103字节授权过期时间戳int64。104-135字节附加参数区预留32字节。当前用途是授权类型标志1表示试用版2表示正式版。136-255字节RSA-2048签名区对前136字节的原始数据做SHA-256摘要后用私钥加密生成的签名。可能有人会问为什么用RSA非对称签名而不是直接在授权文件里塞一个密钥做HMAC因为HMAC需要App里保存对称密钥只要别人逆向拿到密钥就能自己伪造授权文件安全性完全崩塌。RSA方案是私钥只留在你手里App里只放公钥即使被逆向也只能验证、不能签发这是离线授权里最关键的安全设计。签发端生成授权文件时用openssl命令即可完成核心操作# 生成RSA密钥对私钥用于签发公钥内置到App openssl genpkey -algorithm RSA -out license_private.pem -pkeyopt rsa_keygen_bits:2048 openssl rsa -in license_private.pem -pubout -out license_public.pem # 对授权文件原始数据部分前136字节做摘要并签名 openssl dgst -sha256 -sign license_private.pem -out signature.bin license_data.binApp端校验时用内置公钥对签名区做验签同时重新计算前136字节的SHA-256比对摘要结果是否一致。只要数据区任何一个字节被改动验签必然失败。这一步是授权方案的地基后续所有设备绑定、有效期判断都建立在验签通过的基础上。2.3 授权信息存储放哪里才不容易被删授权文件本身是给用户拷贝的一般放在外部存储或App私有目录的Download子目录下但App内部校验通过后最好把解析出的关键信息设备指纹、过期时间、授权类型单独存到私有数据目录里一份用SharedPreferences或文件都行。这样做有两个目的后续每次启动不必重新扫描外部存储直接读本地缓存启动速度快。防止用户把授权文件拷到别处、改了再放回来缓存与外部文件比对不一致时可以直接判定异常重新走完整校验流程。我实际用的方案是授权文件放在/sdcard/Android/data/org.mavlink.qgroundcontrol/files/license/目录下App外部私有目录第一次校验通过后将关键校验值存到/data/data/org.mavlink.qgroundcontrol/files/license_cache.binApp内部私有目录。内部文件外人是没法直接访问的只有root设备或开USB调试才能摸到安全等级明显高一些。注意不要把授权缓存和原始授权文件混在一起存到外部存储。外部存储对用户可见、可删、可改本身就不能作为可信存储区只能作为授权文件的导入通道。3. Android端集成实操3.1 QGC Android工程环境准备QGC的Android工程本质上是一个Qt项目不是传统的Android Studio Gradle工程。源码根目录下的qgroundcontrol.pro定义了整个构建过程编译时会先生成Android平台相关的stub工程然后打包APK。所以你在动手加授权代码之前先把编译环境捋顺Qt版本QGC 4.x系列用的是Qt 5.15.2推荐直接用源码里捆绑的Qt版本或官方下载的对应版本。Android SDK与NDKSDK建议API 30以上NDK用r21系列比较稳太新的NDK版本容易碰到Qt不兼容的问题。JDK1.8或11都可取决于你用的Qt Creator版本。构建工具链在Qt Creator里打开qgroundcontrol.pro选择Android Kit先做一次全量构建确认基线工程能出包。这一步别图省事跳过。我见过不少人在二次开发一开始就卡在编译环境上折腾两三天毫无进展。最稳妥的做法是先用一个空分支做全量编译确认环境没问题再切回你的二次开发分支。这样至少能区分问题是环境导致还是代码导致。授权模块的代码组织上我建议在源码目录下新建一个独立目录例如src/license/把license_manager.h、license_manager.cpp、license_verify.h、license_verify.cpp放在里面然后在qgroundcontrol.pro里追加这些源文件。不要在现有类里直接堆授权逻辑QGC本身几十万行代码你堆在主启动类里后期维护就是灾难。3.2 授权核心类LicenseManager实现授权模块的核心类是LicenseManager用C编写继承QObject方便和QGC现有的信号槽体系对接。关键接口如下class LicenseManager : public QObject { Q_OBJECT public: enum LicenseStatus { LicenseValid 0, LicenseMissing, LicenseInvalidSignature, LicenseDeviceNotMatched, LicenseExpired, LicenseNotYetValid, LicenseCorruptedCache, LicenseSystemError }; explicit LicenseManager(QObject *parent nullptr); // 调用入口App启动时执行完整校验流程 LicenseStatus verifyLicense(const QString licenseFilePath); // 获取当前设备指纹调用Android JNI层 QString getDeviceFingerprint(); // 读取授权文件并解析核心字段 bool parseLicenseFile(const QString path, LicenseInfo *info); // RSA验签 bool verifySignature(const QByteArray data, const QByteArray signature); // 有效期和设备绑定检查 LicenseStatus checkLicenseInfo(const LicenseInfo info); signals: void licenseVerificationFinished(LicenseStatus status); };设备指纹的获取在Android上通过JNI调用Java层实现。流程是在AndroidManifest.xml里不申请任何危险权限只在Java层写一个DeviceFingerprintHelper类收集Settings.Secure.ANDROID_ID、Build.BOARD、Build.MODEL等字段拼成一个字符串返回给C层。注意这里不要用TelephonyManager拿IMEI前文已经说过权限限制问题没必要为了一个授权功能去追着用户要电话权限被拒了反而坏事。RSA验签的C实现可以用Qt自带的QSslKey和QSsl模块避免引入额外的OpenSSL依赖。QGC本身已经链接了SSL库直接调用就行。公钥字符串直接以常量形式硬编码在代码里static const char *kLicensePublicKeyPem -----BEGIN PUBLIC KEY-----\n MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n -----END PUBLIC KEY-----\n;注意公钥字符串别直接以明文常量形式写在一个容易被发现的地方。可以拆成几段启动时动态拼接或者用XOR做一次简单编码运行时解码。这样能挡住大多数只会strings命令扫APK的初级逆向玩家。3.3 集成到QGC启动流程中QGC的启动入口是QGCApplication在main.cpp里构造。我的做法是在QGCApplication构造函数的末尾插入授权校验逻辑这样能保证授权检查发生在所有核心子系统初始化之前QGCApplication::QGCApplication(int argc, char *argv[]) : QApplication(argc, argv) { // ...原有初始化代码... // 授权校验不通过则进入受限模式或直接退出 LicenseManager *licenseMgr new LicenseManager(this); LicenseManager::LicenseStatus status licenseMgr-verifyLicense(kLicenseFilePath); if (status ! LicenseManager::LicenseValid) { handleLicenseFailure(status); return; } // ...继续正常初始化流程... }kLicenseFilePath这个路径怎么定我的经验是优先检查App内部私有目录下的缓存文件/data/data/org.mavlink.qgroundcontrol/files/license_cache.bin如果存在且校验通过直接放行如果不存在或校验失败再去找外部存储里的原始授权文件/sdcard/Android/data/org.mavlink.qgroundcontrol/files/license/license.dat。找不到就弹窗提示用户导入授权文件。校验失败的UI处理不要直接弹个英文Dialog说License invalid用户体验很差。我在QGC的QML层加了一个简单的授权页面显示中文提示“未检测到有效授权文件请联系授权方导入”同时提供一个“重新扫描”按钮。这样客户拿到App就知道该怎么操作不需要你远程教学。3.4 授权有效期与时钟篡改防护离线授权最容易被钻的空子就是改系统时间把时间调到过期日之前就能绕过有效期检查。纯粹的本地校验对这个问题没有完美解法但可以做得让破解成本远大于收益每次校验通过后在内部缓存文件里记录一个“最近一次校验时间”。下次校验如果发现当前系统时间比缓存时间还早说明用户把时间往回拨了直接判定授权异常提示重新导入授权文件。过期时间不留缓冲期。很多App为了用户体验给7天宽限期结果被破解者利用宽限期反复改时间续命。我的做法是过期后立刻失效宁可少赚一个客户也别让方案形同虚设。兼容时区问题所有时间戳统一存UTC校验时把本地时间转成UTC再比较。不处理时区的话用户在UTC14和UTC-12之间来回切换就能薅出26小时的时间差一年多出好几天虽然影响不大但这属于明显的低级漏洞。时间防护这部分做到位了离线授权就基本站得住脚了。聪明一点的破解者看到改时间没用就会去打设备指纹的主意那我们就得在设备指纹采集上再下功夫。4. 常见问题与排查技巧实录4.1 设备指纹采集失败或变化这是我在实际项目中遇到最多的一个问题。典型场景有两种用户机型比较特殊Settings.Secure.ANDROID_ID返回空字符串或全零值。多见于一些山寨设备或定制ROM。解决办法是增加兜底字段比如用Build.FINGERPRINT或Build.SERIAL参与拼接保证至少有一个值非空。用户开过“备份还原”功能换新手机后Android ID被系统恢复成旧值导致指纹匹配错误授权失效。这个属于系统行为我们控制不了但可以在授权提示里写明“更换设备后需重新申请授权”减少售后沟通成本。还有一种情况是同一台设备上装了Debug版和Release版APK签名不一致导致Android ID不同指纹就不一样。我自己就踩过这个坑开发阶段用debug签名测授权一切正常打成release包发给客户指纹变了授权全部失效。排查了半天才意识到是签名变了。所以测试授权流程时一定要从始至终使用最终的Release签名包。4.2 QGC升级后授权失效QGC版本升级时Android包名如果不变、签名不变授权缓存理论上应该继续有效。但如果QGC升级后改了applicationId或者从org.mavlink.qgroundcontrol改成了你自定义的包名App的私有目录路径就变了原来的授权缓存就找不到了。另外QGC的Android包名在qgroundcontrol.pro里有相关配置如果你把QGC_ANDROID_PACKAGE_NAME改掉了所有已分发设备的授权都作废用户必须重新申请。这个决策一定要在发布第一版之前想清楚你猜客户在一台已经部署了20架无人机的手持终端上重装授权文件会是什么心情。4.3 授权文件被拷贝到多台设备离线授权文件既然放在外部存储就免不了被U盘拷来拷去。设备绑定校验就是为了防这个。但要注意如果两台设备的指纹恰好相同概率极低但理论上存在授权文件就能互通。这个风险在当前方案里无法完全杜绝毕竟Android ID本身不是绝对唯一的。我的处理办法是在签发授权文件时额外记录一份“客户名称”字段App启动弹窗里显示“本机授权给某某单位”。一旦发现客户把授权文件外传从流出平台的日志或水印能追溯到源头。这不是技术防破解但能起到威慑和溯源的作用。4.4 纯C代码被误报杀毒或加固工具兼容问题在Android端集成较多原生代码后有些杀毒软件或加固平台会误报。QGC本身包体积就不小加上Qt库、飞控库、授权模块APK轻轻松松过100MB。如果你后续还想上商业加固像腾讯乐固、爱加密这类工具一定要提前确认它们对Qt框架App的兼容性。我在另一个项目上遇到过加固后QGC的QML资源加载异常的问题查了一天多最后发现是加固工具重写了APK的assets目录索引。这类问题没有通用的排查套路只能靠多测。4.5 常见问题速查表问题现象可能原因排查与解决授权校验始终失败日志提示签名错误授权文件被修改、内置公钥和签发私钥不匹配重新生成授权文件确认App内公钥与签发私钥是同一对同一台设备几个APK的授权不通用不同签名的App拿到的Android ID不同全项目统一发布签名测试期用release包验证授权文件导入后提示设备不匹配设备指纹采集字段变化或换了手机检查设备指纹生成逻辑中的兜底字段重新申请授权时间改回之前授权立刻失效系统检测到时钟回拨正常现象按设计执行失效策略引导用户校准时间授权文件以二进制形式拷贝到设备后路径不可见Android 11 对/sdcard/Android/data/访问限制改用App内部私有目录导入或利用系统文件选择器选择文件QGC启动卡在白屏无授权提示授权页面资源未加载在handleLicenseFailure中增加日志输出确认QML页面加载路径5. 新设备扫码导入与路径兼容处理5.1 适配Android 11以上目录访问限制Android 11API 30开始系统限制了第三方应用直接访问/sdcard/Android/data/目录的能力很多老式“把授权文件放到指定目录”的操作对用户来说变得非常不直观。如果你不能保证客户都是装机老手就得考虑更友好的导入方式。我采用的方案是双通道导入传统通道授权文件放到App外部私有目录/sdcard/Android/data/包名/files/license/App扫描时直接读。这个通道保留给运维人员批量部署时用效率高。用户通道在授权引导页里放一个“选择授权文件”按钮调用系统文件选择器Intent.ACTION_OPEN_DOCUMENT让用户从U盘、下载目录、蓝牙接收目录里选license.dat文件App通过ContentResolver读取内容复制到内部私有目录后执行校验。文件选择器这个方案对普通用户最友好他们不需要知道“授权文件应该放在哪里”只要能从微信里收到并打开就行。但这个功能需要在QGC的QML层调用Android原生ActivityQGC框架本身提供了一些Android接口但要用到Intent和ContentResolver建议写一个Java辅助类通过Qt的QtAndroid命名空间调用。5.2 授权文件二维码编码与扫码导入对于深度行业客户运维人员要给几十台设备分发授权文件一台台连U盘太费劲。我后来加了一个增强方案把授权文件编码成二维码用手机扫码自动导入。具体做法是将授权文件的原始内容做Base64编码后拼接一个简单的URL Scheme例如qgclicense://import?dataxxx再用二维码库生成图片。客户用QGC Android版内建的扫码功能扫一下App解析出Base64数据解码后写入临时文件并触发校验流程。由于RSA-2048签名加数据区总共256字节Base64编码后约350个字符刚好能塞进一个中等密度的二维码。这个方案比U盘拷贝和文件选择器都快适合现场批量部署场景。注意二维码本身是明文编码除了RSA签名保护的数据区外其他人扫描二维码只能看到一串Base64字符没有私钥无法伪造安全性不受影响。但二维码容易被人拍照转发所以设备绑定校验在这个场景里更不能省。5.3 离线授权与远程升级的协作关系最后补充一个与授权直接相关的设计点QGC二次开发必然面临版本迭代而离线授权方案如果在每次升级后都要求重发授权文件运维成本和客户体验都会非常糟糕。我的做法是授权文件里增加“最小兼容版本号”字段而不是绑定某个精确的App版本。QGC App升级时检查授权文件里的最小兼容版本是否小于等于当前版本号满足就直接放行不满足才要求更新授权。这样既允许你推送小版本修复又能对大版本升级做授权策略调整是离线授权方案和企业级软件分发比较合理的一种折中。我个人在实际项目里的体会是离线授权方案的核心难点不在算法也不在代码量而在于你对目标设备、目标用户、可能攻击手段的理解。QGC二次开发本来就够复杂再叠加上授权模块稍不留神就会在某个边缘机型的适配、某个系统版本的目录限制上翻车。建议你把授权模块单独抽出来做单元测试设备指纹采集、验签、有效期判断这几个功能点都跑通之后再接到QGC主工程里定位问题能省一半时间。最后再提醒一句授权方案要趁早做不要等APK已经分发出去几百台了才想起来补那时候改包名不是改签名也不是进退两难。