简介:一份面向开发者的OFD电子签章实现Demo及配套使用说明,聚焦开放固定版式文档下的签章原理与编码落地。资源围绕数字证书申请、文档哈希计算、签章生成与插入、接收方验证等完整环节展开,配有可对照阅读的使用说明,适合需要集成OFD签章能力或入门国标电子文档处理的Java工程师。包体共606个文件,以Java源码为主,辅以31个OFD样例文件、21个XML配置文件、16个Markdown说明文档,以及电子印章(esl)、数字证书(p12/der)与PDF、字体等辅助素材,压缩包整体约76.61MB。当前已有330人学习,其中ofdrw相关Demo提供了可运行的代码骨架,从证书管理到签章验签的调用链均有呈现;开发者可结合Markdown说明快速定位关键接口,理解对象操作与签章服务的设计思路,并在此基础上修改复用,形成符合自身业务场景的OFD签章解决方案。
1. 先搞清楚这份 OFD 签章 Demo 能帮你解决什么
如果一个政务申报系统要把线下盖章搬上线,技术侧第一道关卡往往是 OFD 电子签章。这个项目就是围绕「OFD 电子签章」落地的完整 Demo:它不负责业务审批流,只负责一件事——把一份 OFD 文件签上章、验得回来,并且把过程中的踩坑点写进使用说明文档里。适合两类人:一类是马上要对接签章服务的 Java 后端,另一类是想研究 OFD 文档结构和国密签名链路、手头缺一个可跑参照物的开发者。签章这件事有个反直觉的地方:它不是把章图贴上去就完事,每盖一次章都在改文档结构,验签时要把签名信息从文档里读出来,再回头校验整个包的完整性,这份 Demo 把这条链路完整走了一遍。
2. OFD 签章前夜:版式文档解析与文档结构拆解
2.1 打开 OFD 的第一件事:它不是 PDF,而是一个 ZIP 包
第一次接触 OFD 的人最容易想当然:它长得像 PDF,就拿 PDF 解析的思路去拆,结果连文件都读不出来。实际上 OFD 是一个 ZIP 容器,里面装的是 XML 描述文档结构、图片资源、字体资源,签章后的签名数据也写在包里。所以解析 OFD 的第一步永远是解压,第二步是找到入口文件。
入口文件路径是约定好的:根目录下OFD.xml指向文档主体,真正的页面内容在Doc_0/Document.xml,签名相关节点在Doc_0/Signs/Signatures.xml,这三个文件是签章操作的主战场。下面的代码演示最基础的包读取:
public OfdPackage load(Path ofdPath) throws IOException { Map<String, byte[]> entries = new HashMap<>(); try (ZipFile zip = new ZipFile(ofdPath.toFile())) { Enumeration<? extends ZipEntry> enu = zip.entries(); while (enu.hasMoreElements()) { ZipEntry entry = enu.nextElement(); if (!entry.isDirectory()) { entries.put(entry.getName(), zip.getInputStream(entry).readAllBytes()); } } } // 解析入口文件,拿到文档根路径 byte[] ofdXml = entries.get("OFD.xml"); String docRoot = parseDocRoot(ofdXml); // 通常返回 Doc_0 return new OfdPackage(entries, docRoot); }这段代码把整个 OFD 包解成一个内存 Map,后续改包、加签名、塞印章图片都基于这个 Map 再重新打包。parseDocRoot读的是OFD.xml里的DocBody/DocFileLoc节点。注意入口文件名大小写敏感,很多奇奇怪怪的“文件损坏”问题都出在解压工具把文件名改了大小写。
2.2 页面渲染与印章坐标:从文档坐标系到页面坐标
签章落点是个高频翻车点。OFD 使用物理坐标系,单位是毫米,原点在页面左上角,X 轴向右、Y 轴向下。这和前端 Canvas、PDF 的坐标系都不一样,直接用图片宽高当坐标去盖,章基本都会跑偏。
页面尺寸定义在Document.xml的CommonData/PageArea里,关键节点是PhysicalBox,四个值依次是左上角 X、左上角 Y、宽度、高度。常见页面尺寸是宽 210、高 297(A4)。印章定位要做一次换算:把业务系统里的坐标(可能是像素)转成毫米。这里给一个简化版的换算片段:
public Point toOfdPoint(Point px, PageInfo page, double dpi) { double mmX = px.getX() / dpi * 25.4 + page.getOffsetX(); double mmY = px.getY() / dpi * 25.4 + page.getOffsetY(); return new Point((int) Math.round(mmX), (int) Math.round(mmY)); }dpi取 96 是屏幕常用值,page.getOffsetX/getOffsetY来自PhysicalBox前两个值,一般是 0。如果发现章盖到了红线框外,优先怀疑两件事:单位被当成像素没换算,或者 Y 轴方向取反了。点转换对了,后面塞 Draw 指令才谈得上准。
2.3 签名文件节点:Signatures.xml 里到底存了什么
签章之后,OFD 包里的Signatures.xml会多出一组签名节点。它长什么样直接决定验签和阅读器识别能不能成功。标准做法是每个签名一个Signature节点,内部挂SignedInfo、SignatureValue、StampAnnot,结构大致如下:
<Signature> <SignedInfo> <Provider> <ProviderName>签名服务</ProviderName> <Version>1.0</Version> </Provider> <SignatureMethod>urn:oid:1.2.156.10197.1.501</SignatureMethod> <DateTime>2024-05-20 10:30:00</DateTime> <Reference> <DigestMethod>urn:oid:1.2.156.10197.1.401</DigestMethod> <DigestValue>base64编码的摘要</DigestValue> </Reference> </SignedInfo> <SignatureValue>base64编码的签名值</SignatureValue> <StampAnnot>签章图元位置与资源引用</StampAnnot> </Signature>SignatureMethod和DigestMethod里写的 OID 对应 SM2 和 SM3 算法,这是国密体系的标识。Reference里保留的是关键文档节点的摘要,验签的时候要从这里取下摘要值重新比对。StampAnnot则描述章图在页面哪个位置、引用了包内哪个图片资源。这三个节点的内容相互关联,任何一处生成逻辑不一致,后面验签都会死。
3. 核心模块拆解:一个电子签章从计算到落章的完整链路
3.1 摘要计算:SM3 先算文档哈希,不是对全文件无脑算
我看到不少初版实现是这样的:把整个 OFD 文件读进来,直接 SM3 出摘要,再拿摘要去签名。这个做法在封闭系统里自测能通,但一换验签方就废——因为 OFD 包内文件顺序、压缩方式稍微不同,整个文件字节流就变了,摘要必然对不上。
正确做法是只对参与签名的结构化数据计算摘要。常见做法是把SignedInfo里参与签名的若干节点按约定顺序规范化,拼成一段规范字节流,再做 SM3。这段字节流必须保证签名和验签时完全一致,所以不能依赖 XML 的格式化输出,要自己固定拼接逻辑:
public byte[] buildSignedData(SignatureMeta meta) { ByteArrayOutputStream bos = new ByteArrayOutputStream(); // 固定顺序:算法标识 -> 时间 -> 引用节点 writeOid(bos, meta.getSignatureMethod()); writeString(bos, meta.getDateTime()); writeBytes(bos, meta.getReferenceDigest()); byte[] data = bos.toByteArray(); // 国密环境里常用 BC 库的 SM3 实现 SM3Digest digest = new SM3Digest(); digest.update(data, 0, data.length); byte[] hash = new byte[digest.getDigestSize()]; digest.doFinal(hash, 0); return hash; }writeOid、writeString、writeBytes是自定义的定长前缀编码,保证同一份签名字节在任何机器上拼出来都一样。参数里SignatureMeta只包含算法 OID、时间字符串、Reference 摘要值这些稳定字段。这里最容易踩的坑是拼接顺序不一致,后面验签章节会展开讲。
3.2 签名值生成:SM2 签名与 DER 编码格式
拿到 SM3 摘要之后,接着用签名私钥做 SM2 签名。这个 Demo 用的是软证书私钥,也就是从 PFX/P12 证书文件里读出来的一组公私钥对。实际项目里如果你们有 USB Key 或加密机,替换点也在这里。软签名核心代码:
public byte[] sign(byte[] sm3Hash, PrivateKey privateKey) throws Exception { SM2Signer signer = createSM2Signer(); signer.init(true, privateKey); signer.update(sm3Hash, 0, sm3Hash.length); byte[] derSignature = signer.generateSignature(); return derSignature; }这段代码用到了国密库封装好的SM2Signer,init(true, ...)表示签名模式。返回值默认是 ASN.1 DER 编码,兼容性最好。如果某些阅读器要求 raw 格式(R 和 S 各 32 字节拼接),就需要做一次 DER 到 raw 的转换。几个参数说明列在下面,二次开发时最容易搞混的是签名格式,建议做成可配置项:
| 参数 | 说明 | 常见取值 |
|---|---|---|
| sm3Hash | SM3 摘要,32 字节 | 由 3.1 的 buildSignedData 产出 |
| privateKey | 签名私钥 | 软证书私钥或 Key 代理对象 |
| signatureFormat | 签名输出编码 | DER(默认)或 raw R||S |
| provider | 密码学实现 | 国密库提供的 Provider |
3.3 印章版式合成:把印章图片合成进文档并登记签名关系
签名值算好之后,要把章图“落”到页面上。这一步包含三个动作:把印章 PNG 图片作为资源写进 OFD 包;在对应页面的Content.xml里追加一个绘制指令;在Signatures.xml里登记签名节点与印章图元的关联。三个动作顺序不能乱,否则验签工具找不到引用关系。
图片资源写入相对简单,关键是绘制指令的生成。下面的代码模拟向页面内容节点追加一个图片绘制块:
public void appendSealDraw(DocumentXml docXml, SealInfo seal) { String drawBlock = "<DrawResource ID=\"" + seal.getResourceId() + "\"" + " Type=\"Image\" \n" + " Boundary=\"" + seal.getX() + " " + seal.getY() + " " + seal.getWidth() + " " + seal.getHeight() + "\"" + " ResourceID=\"" + seal.getResourceId() + "\"/>"; docXml.getCurrentPage() .appendDraw(drawBlock); }Boundary里的四个数字是印章左上角坐标和宽高,单位毫米,这里的数值必须和 2.2 里换算出来的坐标一致。ResourceID要对应图片资源清单里的 ID,通常是用时间戳生成的唯一标识。这一步做完,视觉上章就算盖上了,但此时还没签名,所以下一页的关键模块才真正让这个章具有防伪能力。
3.4 验签:读回签名并重新计算摘要,反查签名有效性
验签是签章的逆向链路。从Signatures.xml里读出签名值、证书、签名算法和时间,再用同样的buildSignedData规则重新计算摘要,最后用证书公钥验 SM2 签名。核心代码:
public VerifyResult verify(Path ofdPath) throws Exception { OfdPackage pkg = load(ofdPath); SignatureNode sig = pkg.getSignatures().get(0); // 1. 用同一套规则重建待签名字节 byte[] hash = buildSignedData(sig.toMeta()); // 2. 从签名节点取证书公钥 PublicKey pubKey = readCert(sig.getCert()).getPublicKey(); // 3. 校验签名值 SM2Signer verifier = createSM2Signer(); verifier.init(false, pubKey); verifier.update(hash, 0, hash.length); boolean ok = verifier.verifySignature(sig.getSignatureValue()); // 4. 附加检查:印章图片与StampAnnot引用是否一致 boolean stampOk = checkStampRef(pkg, sig); return new VerifyResult(ok && stampOk); }init(false, pubKey)是验签模式。这段代码里最需要注意的一点是:sig.toMeta()必须从签名节点原文里取字段,而不能重新从别处拼数据——很多验签失败就是因为在验签端“好心”修改了字段顺序或补了默认值。第 4 步的checkStampRef是加分项,检查印章图片有没有被替换,实际项目里建议保留,能拦住一部分“改章图”的伪造手段。
4. 常见问题与排查:签章验签失败的几个高频现场
4.1 现象:签完名的文档打不开,提示“包结构错误”
自测签章时文件一切正常,换一台机器、换一个压缩库生成时就报包结构错误。原因多半是向 ZIP 包追加文件时破坏了原有目录结构,或者重打包时把原来的压缩方式改了。解决方法是:始终用内存里的entriesMap 做全量重建,不要用 ZipOutputStream 的追加模式;打包时压缩方式固定为Deflater.DEFAULT_COMPRESSION,不要把STORED和DEFLATED混用。
4.2 现象:印章位置偏移,盖在了红线框外
章能生成,但位置肉眼可见偏了,通常偏在右下角或者完全出了页面边界。原因是坐标换算时把像素直接当毫米用,或者 Y 轴方向反了。OFD 原点是左上角,Y 轴向下为正;如果你之前做过 PDF 签章,可能会习惯左下角原点,拿到 OFD 里就是上下颠倒。解决方法是先在页面四角各打一个测试绘制块,用坐标换算函数跑一遍,确认四个角位置正确之后再换成真印章。
4.3 现象:摘要校验始终不过
签名能生成、也能盖上章,但一验签就报摘要不一致。最常见的原因是签名和验签用了两套序列化逻辑:比如签的时候字段顺序是算法、时间、引用,验的时候无意中调整成时间、算法、引用。因为摘要比对的是规范化后的字节流,顺序一变摘要就变。我的经验是把buildSignedData写成静态工具方法,签名和验签强制共用同一个类,不要各自实现。这个坑属于典型的“自己验自己没事,换个人验就翻车”。
4.4 现象:签名能生成,但第三方阅读器不识别
自研阅读器能打开验签,换官方阅读器或第三方工具直接看不见章。原因通常有两个:一是Signatures.xml里少了StampAnnot节点或引用关系不完整;二是签名值的编码格式不匹配,有的阅读器只认 DER,有的只认 raw。解决方法是先对照标准核对StampAnnot的字段,再在签名接口里加一个format配置项,DER 转 raw、raw 转 DER 都提供工具方法,现场切换测试。第三方兼容性这块没有玄学可讲,只有拿不同阅读器逐个试。
5. 使用说明文档怎么读:从 Demo 到独立集成的最小路径
5.1 解压后的目录结构
这份资源除了源码,还带一份 OFD 使用说明文档。拿到手先别急着跑,按下面这个结构看,能少走弯路:
| 路径 | 内容 | 作用 |
|---|---|---|
| src/main/java | 签章与验签核心代码 | 你要改的业务就集中在这里 |
| src/test/java | 集成测试用例 | 跑通测试就是跑通签章链路 |
| docs/ofd-guide.md | OFD 格式与签章说明 | 讲清每个节点字段的含义 |
| resources/cert | 测试证书与私钥 | 用于本地跑通,上线必须换 |
| resources/seal | 印章图片样例 | 演示印章合成的图片素材 |
| resources/templates | 测试用 OFD 文件 | 签章前原始文档样例 |
资源里的测试证书和印章图片只能用于开发环境,生产环境必须换成目标机构签发的证书和正式印章。使用说明文档建议通读一遍,里面把Signatures.xml每个节点字段对应签章链路里的哪一步标注得很清楚,排查问题时直接翻对应章节比看源码快。
5.2 接口说明:签章、验签、文档解析三个入口
这个 Demo 对外暴露的入口有三个,对应三段主流程:
public interface OfdSignService { // 对指定 OFD 文件签章,返回签章后的文件路径 String signOfd(String ofdPath, String sealImagePath, SealPosition position); // 验证 OFD 文件内的签名,返回结果和签名人信息 VerifyResult verifyOfd(String ofdPath); // 解析 OFD 文件,提取文档基本信息。 // 页面尺寸、页数、资源清单都从这个入口拿 OfdDocumentInfo parseOfd(String ofdPath); }signOfd的参数SealPosition是盖章位置封装,里面包含页码、左上角 X/Y、宽高;verifyOfd返回的VerifyResult会带签名时间和是否通过校验两个关键信息;parseOfd主要用于前置校验:在盖章前先确认文件是标准 OFD、页面尺寸合法,避免把不支持的文档传给签名接口。二次开发时这三个接口基本不用动签名逻辑,主要是替换入参来源和返回值类型。
5.3 配置项:证书、印章、输出路径
配置文件里集中了几个最容易改错的参数,贴出来看一下:
# 签名证书配置 ofd.sign.keystore=classpath:cert/test.pfx ofd.sign.storepass=123456 ofd.sign.alias=test-cert # 印章图片路径 ofd.seal.image=classpath:seal/default.png # 签章输出目录 ofd.output.dir=./outstorepass是证书库密码,测试环境是明文,生产环境建议从配置中心或环境变量读取,不要写死在配置文件里。alias指明证书别名,一个 PFX 里可能有多把证书,必须指定哪一把参与签名。output.dir是签章产物输出目录,注意这个目录需要提前建好,程序不会自动创建多级目录。调试时先把output.dir指到本地临时目录,签完直接打开看效果,确认无误再改到正式路径。
6. 二次开发:把 Demo 改造成自己业务系统的签章服务
要接进真实业务系统,关键改动有三个地方。第一步是把签名核心抽成独立服务,替换证书来源:Demo 用软证书读私钥,你们如果有加密机或统一签章平台,把buildSignedData之后的私钥签名动作替换成远程签名调用,摘要和待签名数据仍然在本地计算,只有最后一步私钥运算外置。第二步是印章位置策略化,现在SealPosition是调用方传入的硬编码坐标,建议改成从业务数据读取,比如根据文档类型匹配预先配置的落章区域,规则存数据库或配置文件,前端就不需要感知坐标细节。第三步是批量签章场景,循环调用signOfd时有几个性能隐患:每次加载证书、每次重建 OFD 包。证书加载可以做成单例缓存,包重建注意不要共享同一份entriesMap,避免多线程污染。改造完的验证方法很简单:把原始 OFD、签章后的 OFD、验签结果三个东西放在一起比对,先跑单元测试,再拿第三方阅读器打开手工看一次章位,只有两步都过了才算真改完。我刚开始接这类需求时,自测总是只验“能不能签”,后来被业务方拿官方阅读器打回一次,说章倾角不对版式不标准,从那以后我每次改造完都强制走一遍“签名—重开—验签—阅读器复核”四步,才敢交给测试。这年头凡是碰签章的项目,流程上省一步,返工成本翻一倍。希望帮到你。
本文还有配套的精品资源,点击获取