Spring Boot实现Agent签名写网关
前一篇讲了为什么 Agent 写数据库前应该先签名。
这一篇直接落地一个最小 Spring Boot Write Gateway。
目标链路:
Agent
↓
Signed Action
↓
Spring Boot Gateway
├─ Signature Verify
├─ Expiry
├─ Idempotency
├─ Authorization
├─ Business Rule
└─ Audit
↓
Database
重点不是把 KMS SDK 塞进 Controller,而是把“状态变更”变成一个可验证的协议。
第一步:定义Action Envelope
public record SignedActionRequest(
String agentId,
String runId,
String subjectId,
String purpose,
String capability,
String resourceId,
String grantId,
String approvalId,
String idempotencyKey,
Instant expiresAt,
JsonNode payload,
String keyId,
String keyVersion,
String signature) {
}
这里几个字段不能省:
agentId
runId
capability
resourceId
idempotencyKey
expiresAt
否则签名很难绑定到真正的业务动作。
第二步:Canonical Payload
签名不能直接对 Java toString()。
定义:
public interface ActionCanonicalizer {
byte[] canonicalize(
SignedActionRequest request);
}
实现时只把参与签名的字段放进去。
@Component
public class JsonActionCanonicalizer
implements ActionCanonicalizer {
private final ObjectMapper mapper;
@Override
public byte[] canonicalize(
SignedActionRequest request) {
ObjectNode node =
mapper.createObjectNode();
node.put("agent_id",
request.agentId());
node.put("run_id",
request.runId());
node.put("subject_id",
request.subjectId());
node.put("purpose",
request.purpose());
node.put("capability",
request.capability());
node.put("resource_id",
request.resourceId());
node.put("grant_id",
request.grantId());
node.put("approval_id",
request.approvalId());
node.put("idempotency_key",
request.idempotencyKey());
node.put("expires_at",
request.expiresAt()
.toString());
node.set("payload",
request.payload());
try {
return mapper
.writer()
.with(
SerializationFeature
.ORDER_MAP_ENTRIES_BY_KEYS)
.writeValueAsBytes(node);
} catch (JsonProcessingException e) {
throw new IllegalStateException(e);
}
}
}
生产环境建议使用真正的 JSON Canonicalization Scheme,而不是只靠 Map 排序。
第三步:先算Action Hash
@Component
public class ActionHasher {
public String sha256(
byte[] content) {
try {
MessageDigest digest =
MessageDigest.getInstance(
"SHA-256");
return HexFormat.of()
.formatHex(
digest.digest(content));
} catch (
NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
这个 Hash 后面同时绑定:
签名
Approval
Idempotency
Audit
不要各算各的。
第四步:Signature Verifier做接口
public interface AgentSignatureVerifier {
VerificationResult verify(
String keyId,
String keyVersion,
byte[] canonicalPayload,
String signature);
}
这样生产可以接:
Cloud KMS
HSM
Vault Transit
AWS KMS
测试环境可以用本地公钥。
本地RSA验证示例
@Component
public class RsaSignatureVerifier
implements AgentSignatureVerifier {
private final AgentPublicKeyStore keyStore;
@Override
public VerificationResult verify(
String keyId,
String keyVersion,
byte[] payload,
String signatureBase64) {
try {
PublicKey key =
keyStore.load(
keyId,
keyVersion);
Signature signature =
Signature.getInstance(
"SHA256withRSA");
signature.initVerify(key);
signature.update(payload);
boolean valid =
signature.verify(
Base64.getDecoder()
.decode(signatureBase64));
return valid
? VerificationResult.valid()
: VerificationResult.invalid(
"SIGNATURE_MISMATCH");
} catch (GeneralSecurityException e) {
return VerificationResult.invalid(
"VERIFY_ERROR");
}
}
}
生产 Agent 不保存私钥。
这里只加载 Public Key。
第五步:Expiry先于数据库写
@Component
public class ActionExpiryValidator {
private final Clock clock;
public void validate(
SignedActionRequest request) {
if (request.expiresAt()
.isBefore(
clock.instant())) {
throw new ActionExpiredException();
}
}
}
Clock 要注入。
以后 Replay / Test 才能固定时间。
第六步:Idempotency必须数据库唯一
@Entity
@Table(
name = "agent_write_idempotency",
uniqueConstraints = {
@UniqueConstraint(
columnNames = "idempotencyKey")
}
)
public class IdempotencyEntity {
@Id
private String id;
private String idempotencyKey;
private String actionHash;
@Enumerated(EnumType.STRING)
private WriteStatus status;
private String resultRef;
private Instant createdAt;
}
状态:
public enum WriteStatus {
STARTED,
COMMITTED,
FAILED,
UNKNOWN
}
为什么要有UNKNOWN
最危险窗口:
数据库提交成功
↓
Gateway返回前进程Crash
调用方重试。
如果只看到:
timeout
很容易重复写。
所以如果结果无法确定:
UNKNOWN
下一次调用必须:
Reconcile
不能直接再次执行。
第七步:同Idempotency Key不同Hash要报警
public IdempotencyDecision check(
String key,
String actionHash) {
Optional existing =
repository.findByIdempotencyKey(
key);
if (existing.isEmpty()) {
return NEW;
}
if (!existing.get()
.getActionHash()
.equals(actionHash)) {
throw new IdempotencyConflictException();
}
return REPLAY;
}
这类冲突不应该返回:
409普通业务错误
而应该进入安全审计。
第八步:Authorization Validator
签名有效不代表有权写。
public interface ActionAuthorizationService {
AuthorizationDecision authorize(
SignedActionRequest request,
String actionHash);
}
至少检查:
Agent Identity
Grant
Purpose
Capability
Resource Ownership
Approval
Approval也绑定同一个Hash
public void requireApproval(
SignedActionRequest request,
String actionHash) {
if (request.approvalId() == null) {
throw new ApprovalRequiredException();
}
ApprovalRecord approval =
approvalRepository
.load(request.approvalId());
if (!approval.actionHash()
.equals(actionHash)) {
throw new ApprovalMismatchException();
}
}
审批以后:
payload任何关键字段变化
都会重新计算 Hash。
旧 Approval 自动失效。
第九步:Business Rule最后再做
例如退款:
@Component
public class RefundBusinessPolicy {
public void validate(
RefundPayload payload,
Order order) {
if (payload.amountMinor()
> order.refundableMinor()) {
throw new RefundLimitException();
}
if (order.status()
== OrderStatus.CLOSED) {
throw new InvalidOrderStateException();
}
}
}
签名和授权都通过以后,仍然必须过业务规则。
第十步:事务边界
@Transactional
public WriteResult execute(
SignedActionRequest request) {
byte[] canonical =
canonicalizer
.canonicalize(request);
String actionHash =
hasher.sha256(canonical);
expiryValidator.validate(request);
signatureService
.requireValid(
request,
canonical);
authorizationService
.requireAllowed(
request,
actionHash);
IdempotencyDecision decision =
idempotencyService
.begin(
request.idempotencyKey(),
actionHash);
if (decision.isReplay()) {
return idempotencyService
.loadResult(
request.idempotencyKey());
}
WriteResult result =
mutationService
.execute(request);
idempotencyService
.markCommitted(
request.idempotencyKey(),
result);
auditService
.append(
request,
actionHash,
result);
return result;
}
如果 Audit 必须和业务写强一致,可以:
Transactional Outbox
不要直接同步发 Kafka。
Audit Event
public record SignedWriteAuditEvent(
String eventId,
String agentId,
String runId,
String subjectId,
String capability,
String resourceId,
String actionHash,
String keyId,
String keyVersion,
String grantId,
String approvalId,
String result,
Instant occurredAt) {
}
不要把完整敏感 Payload 直接写日志。
保存:
Hash
+
Encrypted Evidence Ref
第十一步:Key Registry
@Entity
public class AgentSigningKeyEntity {
@Id
private String keyId;
private String agentId;
private String keyVersion;
private String publicKeyRef;
@Enumerated(EnumType.STRING)
private KeyStatus status;
private Instant validFrom;
private Instant validUntil;
}
状态:
public enum KeyStatus {
ACTIVE,
VERIFY_ONLY,
REVOKED,
EXPIRED
}
轮换后旧 Key:
VERIFY_ONLY
历史记录还能验证,但不能再用于新签名。
第十二步:Agent和Key必须一一约束
如果 Request:
agent_id=refund-agent
却使用:
research-agent-key
必须拒绝。
if (!key.agentId()
.equals(request.agentId())) {
throw new AgentKeyMismatchException();
}
这是非常重要的身份绑定。
第十三步:Metrics
agent_write_total{
capability,
result
}
agent_signature_invalid_total{
reason
}
agent_idempotency_conflict_total
agent_write_unknown_total
agent_approval_mismatch_total
agent_key_mismatch_total
不要把 run_id 放 Metric Tag。
Run 细节放 Trace。
Trace
agent.write
├─canonicalize
├─signature.verify
├─expiry.validate
├─authorization
├─idempotency
├─business.validate
├─mutation
└─audit
一条写操作到底卡在哪层,一眼能看到。
第十四步:失败要分层
public enum WriteFailureCode {
SIGNATURE_INVALID,
KEY_REVOKED,
ACTION_EXPIRED,
AUTHORITY_DENIED,
APPROVAL_MISMATCH,
IDEMPOTENCY_CONFLICT,
BUSINESS_RULE_DENIED,
MUTATION_FAILED,
RESULT_UNKNOWN
}
不要全部抛:
WRITE_FAILED
这会让事故分析非常痛苦。
第十五步:最重要的单元测试
@Test
void changedPayloadMustInvalidateApproval() {
SignedActionRequest request =
fixture.approvedRefund();
String oldHash =
fixture.approvalHash();
SignedActionRequest changed =
fixture.withAmount(
request,
99900L);
assertNotEquals(
oldHash,
hash(changed));
assertThrows(
ApprovalMismatchException.class,
() -> gateway.execute(changed));
}
还要测这些
签名被篡改
Payload被篡改
Key已撤销
Action过期
Agent-Key不匹配
相同Key重复请求
相同Key不同Hash
Grant撤销
Approval Hash不一致
事务提交后响应丢失
最后一个 Case 要配:
UNKNOWN + Reconcile
网关的部署边界
不要让 Agent 同时拥有:
Write Gateway
+
Direct DB
否则所有检查都能绕过。
网络策略:
Agent
→ Write Gateway
允许。
Agent
→ DB
拒绝。
只有 Gateway Service Account 有数据库写权限。
Spring Boot 这层真正解决的不是“怎么验一个RSA签名”。
它把生产 Agent 的写操作固化成一条确定性链:
谁
→签了什么
→基于什么授权
→有没有过期
→是否重复
→业务是否允许
→最终写了什么
只要 Agent 可以改真实状态,这条链就比 Prompt 里的任何“请谨慎操作”更重要。
模型可以负责提出动作。
真正决定能不能落库的,应该一直是这层确定性网关。
更多企业级 AI 应用、Agent、RAG 与模型工程化内容,我会继续整理在 智元界:
https://www.zyentor.com/