-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Validate signature header for incoming POST requests (#42)
Implement various utility methods for validating inngest signatures public `checkHeadersAndValidateSignature`: this can be called from code serving Inngest's request If this function finishes without an exception, the signature is valid private/internal computing HMAC given a hex encoded key and body sign request body + timestamp using signing key in Inngest format (`signkey-<env>-<key>`) validate full signature header including format, timestamp within last 5 minutes, and signature itself Add call to `checkHeadersAndValidateSignature` to spring boot adapter, currently only for POST
- Loading branch information
1 parent
ece6e85
commit 63e02f8
Showing
3 changed files
with
252 additions
and
0 deletions.
There are no files selected for viewing
104 changes: 104 additions & 0 deletions
104
inngest-core/src/main/kotlin/com/inngest/signingkey/SignatureVerification.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
package com.inngest.signingkey | ||
|
||
import com.inngest.InngestEnv | ||
import com.inngest.ServeConfig | ||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | ||
import java.time.Instant | ||
import javax.crypto.Mac | ||
import javax.crypto.spec.SecretKeySpec | ||
|
||
const val HMAC_SHA256 = "HmacSHA256" | ||
|
||
// Implementation of this is inspired by the pure Java example from https://www.baeldung.com/java-hmac#hmac-using-jdk-apis | ||
@OptIn(ExperimentalStdlibApi::class) | ||
private fun computeHMAC( | ||
algorithm: String, | ||
data: String, | ||
key: String, | ||
): String { | ||
val secretKeySpec = SecretKeySpec(key.toByteArray(Charsets.UTF_8), algorithm) | ||
val mac = Mac.getInstance(algorithm) | ||
mac.init(secretKeySpec) | ||
return mac.doFinal(data.toByteArray(Charsets.UTF_8)).toHexString() | ||
} | ||
|
||
internal fun signRequest( | ||
requestBody: String, | ||
timestamp: Long, | ||
signingKey: String, | ||
): String { | ||
return signRequest(requestBody, timestamp.toString(), signingKey) | ||
} | ||
|
||
private fun signRequest( | ||
requestBody: String, | ||
timestamp: String, | ||
signingKey: String, | ||
): String { | ||
val matchResult = SIGNING_KEY_REGEX.matchEntire(signingKey) ?: throw InvalidSigningKeyException() | ||
val key = matchResult.groups["key"]!!.value | ||
val message = requestBody + timestamp | ||
|
||
return computeHMAC(HMAC_SHA256, message, key) | ||
} | ||
|
||
class InvalidSignatureHeaderException(message: String) : Throwable(message) | ||
|
||
class ExpiredSignatureHeaderException : Throwable("signature header has expired") | ||
|
||
const val FIVE_MINUTES_IN_SECONDS = 5L * 60 | ||
|
||
internal fun validateSignature( | ||
signatureHeader: String, | ||
signingKey: String, | ||
requestBody: String, | ||
) { | ||
// TODO: Find a way to parse signatureHeader as URL params without constructing a full URL | ||
val dummyUrl = "https://test.inngest.com/?$signatureHeader" | ||
val url = dummyUrl.toHttpUrlOrNull() ?: throw InvalidSignatureHeaderException("signature header does not match expected format") | ||
val timestamp = url.queryParameter("t")?.toLongOrNull() ?: throw InvalidSignatureHeaderException("timestamp is invalid") | ||
val signature = url.queryParameter("s") ?: throw InvalidSignatureHeaderException("signature is invalid") | ||
|
||
val fiveMinutesAgo = Instant.now().minusSeconds(FIVE_MINUTES_IN_SECONDS).epochSecond | ||
if (timestamp < fiveMinutesAgo) { | ||
throw ExpiredSignatureHeaderException() | ||
} | ||
|
||
val actualSignature = signRequest(requestBody, timestamp, signingKey) | ||
if (actualSignature != signature) { | ||
throw InvalidSignatureHeaderException("signature is invalid") | ||
} | ||
} | ||
|
||
/** | ||
* A function to check if the signature header is valid for a given body. This function completes | ||
* with Unit if everything is valid, otherwise it'll throw a relevant exception | ||
* | ||
* @param signatureHeader The `X-Inngest-Signature` header in the format "t=<seconds_since_unix_epoch>&s=<signature>" | ||
* @param requestBody The request body as a string | ||
* @param serverKind The `X-Inngest-Server-Kind` header, either "dev" or "cloud" | ||
* @param config The current ServeConfig instance, holds relevant environment configuration | ||
*/ | ||
fun checkHeadersAndValidateSignature( | ||
signatureHeader: String?, | ||
requestBody: String, | ||
serverKind: String?, | ||
config: ServeConfig, | ||
) { | ||
val useDevServer = config.client.env == InngestEnv.Dev | ||
|
||
// exit early without checking signature if we are using dev server | ||
if (useDevServer) { | ||
if (serverKind != "dev") { | ||
// TODO: Use a real logger | ||
println("WARNING: using dev server but received X-Inngest-Server-Kind: $serverKind") | ||
} | ||
return | ||
} | ||
|
||
val signingKey = config.signingKey() | ||
|
||
signatureHeader ?: throw InvalidSignatureHeaderException("Using cloud inngest but did not receive X-Inngest-Signature") | ||
|
||
validateSignature(signatureHeader, signingKey, requestBody) | ||
} |
143 changes: 143 additions & 0 deletions
143
inngest-core/src/test/kotlin/com/inngest/signingkey/SignatureVerificationKtTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
package com.inngest.signingkey | ||
|
||
import com.inngest.Inngest | ||
import com.inngest.ServeConfig | ||
import org.junit.jupiter.api.assertDoesNotThrow | ||
import java.time.Instant | ||
import kotlin.test.Test | ||
import kotlin.test.assertEquals | ||
import kotlin.test.assertFailsWith | ||
|
||
class SignatureVerificationKtTest { | ||
val testBody = "hey! if you're reading this come work with us: [email protected]" | ||
val testKey = "signkey-test-12345678" | ||
|
||
@Test | ||
fun `signRequest produces same signatures with different prefix keys`() { | ||
val ts = 1709026298L | ||
val a = signRequest(testBody, ts, "signkey-test-12345678") | ||
val b = signRequest(testBody, ts, "signkey-prod-12345678") | ||
val c = signRequest(testBody, ts, "signkey-staging-12345678") | ||
|
||
assertEquals("3f1c811920eb25da7fa70e3ac484e32e93f01dbbca7c9ce2365f2062a3e10c26", a) | ||
assertEquals(a, b) | ||
assertEquals(a, c) | ||
} | ||
|
||
@Test | ||
fun `fails with an invalid signature header`() { | ||
assertFailsWith<InvalidSignatureHeaderException> { | ||
validateSignature("lol", testKey, testBody) | ||
} | ||
} | ||
|
||
@Test | ||
fun `fails with an invalid timestamp`() { | ||
assertFailsWith<InvalidSignatureHeaderException> { | ||
validateSignature("t=what&s=yea", testKey, testBody) | ||
} | ||
} | ||
|
||
@Test | ||
fun `fails with an expired timestamp`() { | ||
val now = Instant.now().epochSecond | ||
val oneHourAgo = now - 60 * 60 | ||
assertFailsWith<ExpiredSignatureHeaderException> { | ||
validateSignature("t=$oneHourAgo&s=yea", testKey, testBody) | ||
} | ||
} | ||
|
||
@Test | ||
fun `fails with a signing key that wasn't the one used to create the signature`() { | ||
val now = Instant.now().epochSecond | ||
val signature = signRequest(testBody, now, testKey) | ||
|
||
assertFailsWith<InvalidSignatureHeaderException> { | ||
validateSignature("t=$now&s=$signature", "signkey-test-badkey", testBody) | ||
} | ||
} | ||
|
||
@Test | ||
fun `validateSignature succeeds if signature matches and timestamp is within a reasonable time`() { | ||
val now = Instant.now().epochSecond | ||
val signature = signRequest(testBody, now, testKey) | ||
|
||
assertDoesNotThrow { | ||
validateSignature("t=$now&s=$signature", testKey, testBody) | ||
} | ||
} | ||
|
||
@Test | ||
fun `succeeds without signing key or signature if in dev`() { | ||
val testConfig = | ||
ServeConfig( | ||
Inngest("unit-test", env = "dev"), | ||
signingKey = null, | ||
) | ||
assertDoesNotThrow { | ||
checkHeadersAndValidateSignature( | ||
null, | ||
testBody, | ||
null, | ||
testConfig, | ||
) | ||
} | ||
} | ||
|
||
@Test | ||
fun `fails if in prod and signing key is missing`() { | ||
val testConfig = | ||
ServeConfig( | ||
Inngest("unit-test", env = "prod"), | ||
signingKey = null, | ||
) | ||
val exception = | ||
assertFailsWith<Exception> { | ||
checkHeadersAndValidateSignature( | ||
null, | ||
testBody, | ||
null, | ||
testConfig, | ||
) | ||
} | ||
assertEquals("signing key is required", exception.message) | ||
} | ||
|
||
@Test | ||
fun `fails if in prod with signing key but signature is missing`() { | ||
val testConfig = | ||
ServeConfig( | ||
Inngest("unit-test", env = "prod"), | ||
signingKey = testKey, | ||
) | ||
assertFailsWith<InvalidSignatureHeaderException> { | ||
checkHeadersAndValidateSignature( | ||
null, | ||
testBody, | ||
null, | ||
testConfig, | ||
) | ||
} | ||
} | ||
|
||
@Test | ||
fun `checkHeadersAndValidateSignature succeeds if signature matches and timestamp is within a reasonable time`() { | ||
val testConfig = | ||
ServeConfig( | ||
Inngest("unit-test", env = "prod"), | ||
signingKey = testKey, | ||
) | ||
|
||
val now = Instant.now().epochSecond | ||
val signature = signRequest(testBody, now, testKey) | ||
|
||
assertDoesNotThrow { | ||
checkHeadersAndValidateSignature( | ||
"t=$now&s=$signature", | ||
testBody, | ||
null, | ||
testConfig, | ||
) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters