Callback Signature Verifier
Verifies callback signatures from SEP-12 and SEP-31 anchor servers.
Anchors sign outbound webhook requests with their SIGNING_KEY so that the receiving party can prove authenticity of the inbound callback. Both SEP-12 (KYC customer status updates) and SEP-31 (cross-border payment status updates) define the same signing format, and this class serves both.
Signature header
The anchor sends one of:
Signature: t=<unix-seconds>, s=<base64-signature>(preferred)X-Stellar-Signature: t=<unix-seconds>, s=<base64-signature>(legacy)
When both headers are present the verifier always uses Signature. A malformed Signature returns Result.MalformedHeader even if X-Stellar-Signature would have verified — the preference for the non-deprecated header is firm and there is no silent fallback.
Canonical payload
The signed bytes are "$timestamp.$host.$body" encoded as UTF-8, where:
timestampis the Unix seconds value from the headert=parameter.hostis derived from the URL passed to the constructor as registeredCallbackUrl (port is stripped).bodyis the byte-exact inbound request body, decoded as UTF-8.
The host is taken from the constructor-supplied URL and never from any inbound HTTP header. This pins the verification to the URL the consumer previously registered with the anchor and prevents man-in-the-middle tampering with a Host header.
Port stripping
The verifier strips port from the host (e.g., myapp.com:8443 becomes myapp.com). The SEP specifications do not say whether the port should be included in the signed payload, and the SDK pins to host-only for unambiguity. An anchor that signs with port included will fail with Result.SignatureMismatch; file an upstream bug against that anchor.
Freshness window
The default freshness window is 120 seconds, which matches the SEP-31 specification's recommendation. The verifier applies a two-sided check: abs(currentTime - timestamp) > freshnessSeconds is rejected. The specifications define a one-sided check that lets future-dated timestamps through; the two-sided form rejects them as well and is documented as the behavior of this class. Result.Stale.ageSeconds carries a signed value so callers can distinguish replay (positive age) from clock-skew or forgery (negative age) in logs.
Setting freshnessSeconds above 120 deviates from the spec recommendation and exists only for clock-skew tolerance in test environments. Production should keep the default.
stellar.toml
The verifier does NOT fetch the anchor's stellar.toml. The caller looks up the anchor's SIGNING_KEY once and caches it for the lifetime of the connection:
val signingKey = StellarToml.fromDomain("anchor.example.org")
.generalInformation.signingKey
?: error("Receiving Anchor stellar.toml has no SIGNING_KEY")HTTPS requirement
The constructor rejects non-HTTPS URLs unless the host is one of the IETF loopback authorities (localhost, 127.0.0.1, [::1], each optionally with a port). The loopback exception exists so that callers can integrate against a local Anchor Platform instance during development without standing up a TLS-terminating proxy.
Thread safety
Instances are immutable and safe to share across threads. Construct one verifier per registered callback URL and reuse it for every inbound callback against that URL.
Example: SEP-31 callback verification
val verifier = CallbackSignatureVerifier(
signingKey = receivingAnchorSigningKey,
registeredCallbackUrl = "https://sending-anchor.example.org/sep31-callback",
)
when (val result = verifier.verify(
signatureHeader = call.request.headers["Signature"],
xStellarSignatureHeader = call.request.headers["X-Stellar-Signature"],
body = call.receiveText(),
)) {
CallbackSignatureVerifier.Result.Valid -> processCallback()
is CallbackSignatureVerifier.Result.Stale ->
log.warn("Callback stale by ${result.ageSeconds}s")
CallbackSignatureVerifier.Result.SignatureMismatch,
CallbackSignatureVerifier.Result.MalformedHeader,
CallbackSignatureVerifier.Result.MissingHeader -> reject()
}