如何通过Protobuf反射实现带加密签名的确定性序列化?
我需要实现Protobuf消息的确定性序列化,这样才能给消息附加加密签名——因为不同序列化器生成的二进制数据可能不一样,会导致签名失效。
Proto3发布说明里提到:
不过,确定性序列化在不同语言间不具备规范性;且在存在未知字段的架构变更后,不同构建版本间也不稳定。需要规范性序列化的用户(例如以规范形式持久化存储、指纹识别等场景)应自行定义规范化规范,并使用反射API实现序列化器,而非依赖此API。
我的Proto定义如下:
package Something; message Request { Payload payload = 1; // 序列化后payload的字节签名 bytes signature = 2; message Payload { string user_id_from = 1; uint64 amount = 2; string user_id_to = 3; } }
请问怎么用Protobuf反射来实现这个需求?
要实现确定性序列化,核心是自定义一套严格无歧义的序列化规则,再通过Protobuf反射API严格遵循规则序列化字段,确保相同消息内容在任何环境下都生成完全一致的二进制字节流。下面以Java为例(其他语言思路一致,仅反射API细节不同),一步步说明实现方式:
1. 先明确你的确定性序列化规则
这是最关键的一步,规则必须清晰可落地,比如:
- 只序列化已知字段(忽略未知字段,避免架构变更影响签名一致性)
- 字段必须按字段编号从小到大的顺序序列化
- 基础类型的编码严格对齐Protobuf规范:
- 字符串强制用UTF-8编码,保留原始内容(不做额外空白处理)
- 数值类型(如
uint64)按Protobuf Varint编码规则处理 - 嵌套消息递归应用相同的确定性规则
- 明确是否序列化默认值:建议序列化所有字段(包括默认值),避免不同语言对默认值的处理差异
2. 利用Protobuf反射API实现序列化
我们需要单独序列化Request.Payload来生成签名,步骤如下:
步骤1:获取消息的描述符与反射入口
先拿到Payload的元数据和反射操作对象:
import com.google.protobuf.Descriptors; import com.google.protobuf.Message; import com.google.protobuf.WireFormat; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Comparator; import java.util.List; // 假设已有一个构造好的Payload实例 Something.Request.Payload payload = Something.Request.Payload.newBuilder() .setUserIdFrom("user_001") .setAmount(5000) .setUserIdTo("user_002") .build(); // 获取消息描述符和反射对象 Descriptors.Descriptor payloadDescriptor = payload.getDescriptorForType(); Message.Reflection reflection = payload.getReflection();
步骤2:按字段编号排序字段
从描述符中取出所有字段,按编号从小到大排序,确保序列化顺序固定:
List<Descriptors.FieldDescriptor> sortedFields = new ArrayList<>(payloadDescriptor.getFields()); sortedFields.sort(Comparator.comparingInt(Descriptors.FieldDescriptor::getNumber));
步骤3:逐个字段序列化,拼接字节流
遍历排序后的字段,按规则序列化每个字段并写入输出流:
ByteArrayOutputStream baos = new ByteArrayOutputStream(); for (Descriptors.FieldDescriptor field : sortedFields) { // 这里选择跳过无值字段,如果需要序列化默认值,可改为处理默认值逻辑 if (!payload.hasField(field)) { continue; } Object value = reflection.getField(payload, field); // 根据字段类型做针对性序列化 switch (field.getType()) { case STRING: String strVal = (String) value; byte[] strBytes = strVal.getBytes(StandardCharsets.UTF_8); // 写入字段标签(编号+类型) writeVarint(baos, makeTag(field.getNumber(), WireFormat.WIRETYPE_LENGTH_DELIMITED)); // 写入字符串长度和字节内容 writeVarint(baos, strBytes.length); baos.write(strBytes); break; case UINT64: long uintVal = (Long) value; writeVarint(baos, makeTag(field.getNumber(), WireFormat.WIRETYPE_VARINT)); writeVarint(baos, uintVal); break; case MESSAGE: // 嵌套消息递归调用确定性序列化方法 Message nestedMsg = (Message) value; byte[] nestedBytes = deterministicSerialize(nestedMsg); writeVarint(baos, makeTag(field.getNumber(), WireFormat.WIRETYPE_LENGTH_DELIMITED)); writeVarint(baos, nestedBytes.length); baos.write(nestedBytes); break; // 其他类型可根据需求补充 } } // 最终得到确定性序列化后的payload字节流 byte[] deterministicPayloadBytes = baos.toByteArray();
辅助方法实现
上面用到的makeTag和writeVarint是Protobuf底层编码逻辑,可直接复用或自行实现:
// 生成Protobuf字段标签:(字段编号 << 3) | wireType private static int makeTag(int fieldNumber, int wireType) { return (fieldNumber << 3) | wireType; } // 写入Varint编码的数值 private static void writeVarint(OutputStream out, long value) throws IOException { while (true) { if ((value & ~0x7FL) == 0) { out.write((int) value); return; } else { out.write(((int) value & 0x7F) | 0x80); value >>>= 7; } } } // 递归的确定性序列化方法,用于嵌套消息 private static byte[] deterministicSerialize(Message msg) throws IOException { Descriptors.Descriptor descriptor = msg.getDescriptorForType(); Message.Reflection reflection = msg.getReflection(); List<Descriptors.FieldDescriptor> sortedFields = new ArrayList<>(descriptor.getFields()); sortedFields.sort(Comparator.comparingInt(Descriptors.FieldDescriptor::getNumber)); ByteArrayOutputStream baos = new ByteArrayOutputStream(); for (Descriptors.FieldDescriptor field : sortedFields) { if (!msg.hasField(field)) continue; Object value = reflection.getField(msg, field); // 重复上面的类型判断与序列化逻辑,或封装成通用方法 } return baos.toByteArray(); }
3. 生成签名并组装完整消息
拿到确定性序列化的payload字节流后,用你的加密算法(如RSA、ECDSA)生成签名,再组装成Request:
// 假设你有现成的签名工具方法 byte[] signature = sign(deterministicPayloadBytes, privateKey); Something.Request finalRequest = Something.Request.newBuilder() .setPayload(payload) .setSignature(com.google.protobuf.ByteString.copyFrom(signature)) .build();
4. 签名验证逻辑
验证时,需要从收到的Request中取出payload,用同样的规则序列化后,再用公钥验证签名:
// 假设收到了请求消息 Something.Request receivedRequest = ...; Something.Request.Payload receivedPayload = receivedRequest.getPayload(); byte[] receivedSignature = receivedRequest.getSignature().toByteArray(); // 对收到的payload做确定性序列化 byte[] receivedPayloadBytes = deterministicSerialize(receivedPayload); // 验证签名合法性 boolean isSignatureValid = verify(receivedPayloadBytes, receivedSignature, publicKey);
关键注意事项
- 跨语言一致性:如果系统涉及多语言,必须在所有语言中严格实现相同的序列化规则(字段顺序、默认值处理、编码方式等),否则不同语言生成的字节流会不一致
- 未知字段处理:建议忽略未知字段,避免Proto版本迭代时新增字段破坏旧版本的签名验证
- 性能权衡:反射序列化比原生序列化慢,若对性能要求极高,可考虑用Protobuf插件生成确定性序列化代码,但反射的优势是无需修改Proto定义,灵活性更强
内容的提问来源于stack exchange,提问作者ischepin

