CallbackSignatureVerifier

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:

  • timestamp is the Unix seconds value from the header t= parameter.

  • host is derived from the URL passed to the constructor as registeredCallbackUrl (port is stripped).

  • body is 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()
}

See also

Constructors

Link copied to clipboard
constructor(signingKey: String, registeredCallbackUrl: String, freshnessSeconds: Long = DEFAULT_FRESHNESS_SECONDS, clock: Clock = Clock.System)

Constructs a verifier for callbacks delivered to registeredCallbackUrl.

Types

Link copied to clipboard
object Companion
Link copied to clipboard
sealed interface Result

Outcome of a verify call.

Functions

Link copied to clipboard
suspend fun verify(signatureHeader: String?, xStellarSignatureHeader: String?, body: String): CallbackSignatureVerifier.Result

Verifies a callback signature against the registered URL's host and the supplied body.