Skip to content

Commit 715a304

Browse files
authored
refactor: isolate webhook signature verification (#921)
## Summary Move the existing webhook-signature algorithm out of the generated blocking service into an SDK-owned, Kotlin-internal/JVM-synthetic helper. `verifySignature` is now a thin typed delegate. The algorithm is unchanged, including secret precedence, required-header order, timestamp tolerance, HMAC calculation, timing-safe comparison, and exception messages. The existing `unwrap` implementation and async delegation are unchanged. The mixed-file count remains 61, but the handwritten patch in `WebhookServiceImpl` shrinks from +112/-3 to +26/-3. The other 60 customizations are unchanged. Public service signatures, generation metadata, and API-reference artifacts are unchanged. All existing tests remain intact. The new `WebhookVerificationTest` runs each of these cases through both public client flavors: - `usesClientSecretAndPerCallOverride`: configured secret, per-call override, and mismatch. - `acceptsInclusiveToleranceAndRejectsOutsideIt`: both tolerance boundaries, outside values, and custom tolerance. - `preservesRequiredHeaderAndTimestampErrors`: missing-secret/header precedence and malformed timestamps. - `acceptsRawAndEncodedSecretsAndAnyMatchingSignature`: raw/encoded secrets and multiple signatures. - `unwrapVerifiesBeforeParsing`: valid events, signature failure before parsing, and parse-error wrapping. - `withOptionsUsesUpdatedClock`: the derived service uses the updated clock. Security-focused review is requested because this relocates signature-verification code. ## Test Plan ### Automated - Full formatting, lint, SDK build, and Jackson compatibility: passed. - Existing blocking/async webhook tests plus `WebhookVerificationTest`: 23 passed, no skips. - Baseline and proposed API-compatibility compilation and public-signature checks: passed. - JVM signature comparison for both webhook service APIs and verification parameters: unchanged; the new helper method is synthetic. - Exact token/body comparison: verifier logic and every other service statement are unchanged.
1 parent 95f4a11 commit 715a304

3 files changed

Lines changed: 334 additions & 89 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.openai.services
2+
3+
import com.openai.core.ClientOptions
4+
import com.openai.errors.InvalidWebhookSignatureException
5+
import com.openai.models.webhooks.WebhookVerificationParams
6+
import java.nio.charset.StandardCharsets
7+
import java.security.MessageDigest
8+
import java.time.Instant
9+
import java.util.Base64
10+
import javax.crypto.Mac
11+
import javax.crypto.spec.SecretKeySpec
12+
13+
/** Verifies a webhook using the SDK's configured secret, clock, and tolerance. */
14+
@JvmSynthetic
15+
internal fun verifyWebhookSignature(
16+
clientOptions: ClientOptions,
17+
params: WebhookVerificationParams,
18+
) {
19+
val webhookSecret =
20+
params.secret.orElse(null)
21+
?: clientOptions.webhookSecret().orElse(null)
22+
?: throw IllegalStateException(
23+
"The webhook secret must either be set using the env var, OPENAI_WEBHOOK_SECRET, " +
24+
"on the client class builder, .webhookSecret(...), or passed to this function"
25+
)
26+
27+
// Extract required headers
28+
val signatureHeader =
29+
params.headers.values("webhook-signature").firstOrNull()
30+
?: throw IllegalArgumentException("Missing required webhook-signature header")
31+
32+
val timestampHeader =
33+
params.headers.values("webhook-timestamp").firstOrNull()
34+
?: throw IllegalArgumentException("Missing required webhook-timestamp header")
35+
36+
val webhookId =
37+
params.headers.values("webhook-id").firstOrNull()
38+
?: throw IllegalArgumentException("Missing required webhook-id header")
39+
40+
// Validate timestamp to prevent replay attacks
41+
val timestampSeconds =
42+
try {
43+
timestampHeader.toLong()
44+
} catch (e: NumberFormatException) {
45+
throw InvalidWebhookSignatureException("Invalid webhook timestamp format", e)
46+
}
47+
48+
val now = Instant.now(clientOptions.clock)
49+
val timestampInstant = Instant.ofEpochSecond(timestampSeconds)
50+
val toleranceDuration = params.tolerance
51+
52+
if (timestampInstant.isBefore(now.minus(toleranceDuration))) {
53+
throw InvalidWebhookSignatureException("Webhook timestamp is too old")
54+
}
55+
56+
if (timestampInstant.isAfter(now.plus(toleranceDuration))) {
57+
throw InvalidWebhookSignatureException("Webhook timestamp is too new")
58+
}
59+
60+
// The signature header can have multiple values, separated by spaces.
61+
val signatures =
62+
signatureHeader
63+
.split("\\s+".toRegex())
64+
.filter { it.isNotBlank() }
65+
.map { it.removePrefix("v1,") }
66+
67+
// Decode the secret if it starts with whsec_
68+
val decodedSecret =
69+
if (webhookSecret.startsWith("whsec_")) {
70+
Base64.getDecoder().decode(webhookSecret.substring(6))
71+
} else {
72+
webhookSecret.toByteArray(StandardCharsets.UTF_8)
73+
}
74+
75+
// Create the signed payload: {webhook_id}.{timestamp}.{payload}
76+
val bodyString = String(params.payload, StandardCharsets.UTF_8)
77+
val signedPayload = "$webhookId.$timestampHeader.$bodyString"
78+
79+
// Compute HMAC-SHA256 signature
80+
val mac = Mac.getInstance("HmacSHA256")
81+
val secretKey = SecretKeySpec(decodedSecret, "HmacSHA256")
82+
mac.init(secretKey)
83+
val expectedSignatureBytes = mac.doFinal(signedPayload.toByteArray(StandardCharsets.UTF_8))
84+
val expectedSignature = Base64.getEncoder().encodeToString(expectedSignatureBytes)
85+
86+
// Accept if any signature matches using timing-safe comparison
87+
val signatureMatches =
88+
signatures.any { signature ->
89+
MessageDigest.isEqual(
90+
expectedSignature.toByteArray(StandardCharsets.UTF_8),
91+
signature.toByteArray(StandardCharsets.UTF_8),
92+
)
93+
}
94+
95+
if (!signatureMatches) {
96+
throw InvalidWebhookSignatureException(
97+
"The given webhook signature does not match the expected signature"
98+
)
99+
}
100+
}

‎openai-java-core/src/main/kotlin/com/openai/services/blocking/WebhookServiceImpl.kt‎

Lines changed: 3 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,12 @@ package com.openai.services.blocking
44

55
import com.fasterxml.jackson.module.kotlin.jacksonTypeRef
66
import com.openai.core.ClientOptions
7-
import com.openai.errors.InvalidWebhookSignatureException
87
import com.openai.errors.OpenAIInvalidDataException
98
import com.openai.models.webhooks.UnwrapWebhookEvent
109
import com.openai.models.webhooks.WebhookVerificationParams
10+
import com.openai.services.verifyWebhookSignature
1111
import java.nio.charset.StandardCharsets
12-
import java.security.MessageDigest
13-
import java.time.Instant
14-
import java.util.Base64
1512
import java.util.function.Consumer
16-
import javax.crypto.Mac
17-
import javax.crypto.spec.SecretKeySpec
1813

1914
class WebhookServiceImpl internal constructor(private val clientOptions: ClientOptions) :
2015
WebhookService {
@@ -52,89 +47,8 @@ class WebhookServiceImpl internal constructor(private val clientOptions: ClientO
5247
* @param params Verification parameters including payload, headers, secret and tolerance
5348
* @throws IllegalArgumentException if the signature is invalid
5449
*/
55-
override fun verifySignature(params: WebhookVerificationParams) {
56-
val webhookSecret =
57-
params.secret.orElse(null)
58-
?: clientOptions.webhookSecret().orElse(null)
59-
?: throw IllegalStateException(
60-
"The webhook secret must either be set using the env var, OPENAI_WEBHOOK_SECRET, " +
61-
"on the client class builder, .webhookSecret(...), or passed to this function"
62-
)
63-
64-
// Extract required headers
65-
val signatureHeader =
66-
params.headers.values("webhook-signature").firstOrNull()
67-
?: throw IllegalArgumentException("Missing required webhook-signature header")
68-
69-
val timestampHeader =
70-
params.headers.values("webhook-timestamp").firstOrNull()
71-
?: throw IllegalArgumentException("Missing required webhook-timestamp header")
72-
73-
val webhookId =
74-
params.headers.values("webhook-id").firstOrNull()
75-
?: throw IllegalArgumentException("Missing required webhook-id header")
76-
77-
// Validate timestamp to prevent replay attacks
78-
val timestampSeconds =
79-
try {
80-
timestampHeader.toLong()
81-
} catch (e: NumberFormatException) {
82-
throw InvalidWebhookSignatureException("Invalid webhook timestamp format", e)
83-
}
84-
85-
val now = Instant.now(clientOptions.clock)
86-
val timestampInstant = Instant.ofEpochSecond(timestampSeconds)
87-
val toleranceDuration = params.tolerance
88-
89-
if (timestampInstant.isBefore(now.minus(toleranceDuration))) {
90-
throw InvalidWebhookSignatureException("Webhook timestamp is too old")
91-
}
92-
93-
if (timestampInstant.isAfter(now.plus(toleranceDuration))) {
94-
throw InvalidWebhookSignatureException("Webhook timestamp is too new")
95-
}
96-
97-
// The signature header can have multiple values, separated by spaces.
98-
val signatures =
99-
signatureHeader
100-
.split("\\s+".toRegex())
101-
.filter { it.isNotBlank() }
102-
.map { it.removePrefix("v1,") }
103-
104-
// Decode the secret if it starts with whsec_
105-
val decodedSecret =
106-
if (webhookSecret.startsWith("whsec_")) {
107-
Base64.getDecoder().decode(webhookSecret.substring(6))
108-
} else {
109-
webhookSecret.toByteArray(StandardCharsets.UTF_8)
110-
}
111-
112-
// Create the signed payload: {webhook_id}.{timestamp}.{payload}
113-
val bodyString = String(params.payload, StandardCharsets.UTF_8)
114-
val signedPayload = "$webhookId.$timestampHeader.$bodyString"
115-
116-
// Compute HMAC-SHA256 signature
117-
val mac = Mac.getInstance("HmacSHA256")
118-
val secretKey = SecretKeySpec(decodedSecret, "HmacSHA256")
119-
mac.init(secretKey)
120-
val expectedSignatureBytes = mac.doFinal(signedPayload.toByteArray(StandardCharsets.UTF_8))
121-
val expectedSignature = Base64.getEncoder().encodeToString(expectedSignatureBytes)
122-
123-
// Accept if any signature matches using timing-safe comparison
124-
val signatureMatches =
125-
signatures.any { signature ->
126-
MessageDigest.isEqual(
127-
expectedSignature.toByteArray(StandardCharsets.UTF_8),
128-
signature.toByteArray(StandardCharsets.UTF_8),
129-
)
130-
}
131-
132-
if (!signatureMatches) {
133-
throw InvalidWebhookSignatureException(
134-
"The given webhook signature does not match the expected signature"
135-
)
136-
}
137-
}
50+
override fun verifySignature(params: WebhookVerificationParams) =
51+
verifyWebhookSignature(clientOptions, params)
13852

13953
class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) :
14054
WebhookService.WithRawResponse {

0 commit comments

Comments
 (0)