Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
408 changes: 408 additions & 0 deletions IMPLEMENTATION_SUMMARY.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

175 changes: 175 additions & 0 deletions sdk/src/main/kotlin/com/mobilemoney/sdk/webhook/WebhookVerifier.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package com.mobilemoney.sdk.webhook

import java.security.KeyFactory
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.util.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

/**
* Webhook Signature Verification Helper
*
* Verifies webhook signatures from ProxyPay using either Ed25519 or HMAC-SHA256.
*
* Usage:
* ```kotlin
* val verifier = WebhookVerifier()
* val isValid = verifier.verifySignature(
* payload = requestBody,
* signatureHeader = request.getHeader("X-Webhook-Signature"),
* secret = "your-public-key-or-hmac-secret"
* )
* ```
*/
class WebhookVerifier {

/**
* Verify webhook signature — supports both Ed25519 and HMAC-SHA256.
*
* @param payload The raw request body (String or ByteArray)
* @param signatureHeader The value of X-Webhook-Signature header
* @param secret For HMAC: the webhook secret. For Ed25519: the public key (hex-encoded).
* @return true if signature is valid, false otherwise
*/
fun verifySignature(
payload: Any,
signatureHeader: String,
secret: String
): Boolean {
return try {
val payloadBytes = when (payload) {
is String -> payload.toByteArray(Charsets.UTF_8)
is ByteArray -> payload
else -> return false
}

when {
signatureHeader.startsWith("ed25519:") -> {
val signature = signatureHeader.substring(8) // Remove "ed25519:" prefix
verifyEd25519Signature(payloadBytes, signature, secret)
}
signatureHeader.startsWith("sha256=") -> {
val signature = signatureHeader.substring(7) // Remove "sha256=" prefix
verifyHmacSha256Signature(payloadBytes, signature, secret)
}
else -> false
}
} catch (e: Exception) {
false // Verification errors return false
}
}

/**
* Verify Ed25519 signature
*
* @param payload The payload bytes
* @param signatureBase64 Base64-encoded signature
* @param publicKeyHex The Ed25519 public key in hex format (32 bytes)
* @return true if signature is valid
*/
private fun verifyEd25519Signature(
payload: ByteArray,
signatureBase64: String,
publicKeyHex: String
): Boolean {
try {
val signatureBytes = Base64.getDecoder().decode(signatureBase64)

// Verify signature length (Ed25519 signatures are always 64 bytes)
if (signatureBytes.size != 64) {
return false
}

// Decode the public key from hex
val publicKeyBytes = hexStringToByteArray(publicKeyHex)
if (publicKeyBytes.size != 32) {
return false
}

// Create EdDSA public key using X.509 encoding
// Note: Java's built-in Ed25519 support requires the key in PKIX format
val keySpec = X509EncodedKeySpec(publicKeyBytes)
val keyFactory = KeyFactory.getInstance("EdDSA")
val publicKey = keyFactory.generatePublic(keySpec)

// Verify the signature
val sig = Signature.getInstance("EdDSA")
sig.initVerify(publicKey)
sig.update(payload)
return sig.verify(signatureBytes)
} catch (e: Exception) {
return false
}
}

/**
* Verify HMAC-SHA256 signature (for backward compatibility)
*
* @param payload The payload bytes
* @param signatureHex The hex-encoded signature
* @param secret The webhook secret
* @return true if signature is valid
*/
private fun verifyHmacSha256Signature(
payload: ByteArray,
signatureHex: String,
secret: String
): Boolean {
try {
val mac = Mac.getInstance("HmacSHA256")
val secretKeySpec = SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")
mac.init(secretKeySpec)
val expectedSignature = mac.doFinal(payload).toHexString()

// Constant-time comparison to prevent timing attacks
if (signatureHex.length != expectedSignature.length) {
return false
}

return timingSafeEqual(signatureHex, expectedSignature)
} catch (e: Exception) {
return false
}
}

/**
* Constant-time string comparison to prevent timing attacks
*/
private fun timingSafeEqual(a: String, b: String): Boolean {
var result = 0
for (i in 0 until minOf(a.length, b.length)) {
result = result or (a[i].code xor b[i].code)
}
result = result or (a.length xor b.length)
return result == 0
}

/**
* Convert ByteArray to hex string
*/
private fun ByteArray.toHexString(): String =
joinToString("") { "%02x".format(it) }

/**
* Convert hex string to ByteArray
*/
private fun hexStringToByteArray(s: String): ByteArray {
val len = s.length
val data = ByteArray(len / 2)
for (i in 0 until len step 2) {
data[i / 2] = ((s[i].digitToInt(16) shl 4) + s[i + 1].digitToInt(16)).toByte()
}
return data
}
}

/**
* Convenience function to verify webhook signatures.
* Create a shared instance or use directly.
*/
fun verifyWebhookSignature(
payload: Any,
signatureHeader: String,
secret: String
): Boolean = WebhookVerifier().verifySignature(payload, signatureHeader, secret)
152 changes: 152 additions & 0 deletions src/crypto/ed25519Webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Ed25519 Webhook Signature Utilities
*
* Provides functions for signing and verifying webhook payloads using Ed25519.
* Ed25519 offers:
* - Better security properties than RSA
* - Faster signature generation and verification
* - Smaller key sizes (32 bytes)
* - Deterministic signatures (no randomness needed)
*
* Key Format:
* - Private key: 32 bytes (raw) or hex-encoded string
* - Public key: 32 bytes (raw) or hex-encoded string
* - Signature: 64 bytes (raw) or base64-encoded string
*/

import { createPrivateKey, createPublicKey, sign, verify, generateKeyPairSync } from "crypto";

/**
* Generate a new Ed25519 keypair.
* @returns Object with private and public keys in hex format
*/
export function generateEd25519Keypair(): {
privateKeyHex: string;
publicKeyHex: string;
} {
try {
const { privateKey: privKey, publicKey: pubKey } = generateKeyPairSync("ed25519", {});

// Export as DER/PKCS8 and raw
const privDer = privKey.export({ format: "pkcs8", type: "pkcs8" });
const pubRaw = pubKey.export({ format: "raw", type: "spki" });

// For Ed25519, the private key in PKCS8 format has a specific structure
// Extract the 32-byte seed from PKCS8 (starts at byte 16 after the header)
const privHex = privDer.subarray(16, 48).toString("hex");
const pubHex = pubRaw.toString("hex");

return {
privateKeyHex: privHex,
publicKeyHex: pubHex,
};
} catch (err) {
throw new Error(`Failed to generate Ed25519 keypair: ${err}`);
}
}

/**
* Sign a payload using an Ed25519 private key.
* @param payload - The payload to sign (string or Buffer)
* @param privateKeyHex - The Ed25519 private key in hex format (32 bytes)
* @returns Base64-encoded signature
*/
export function signPayloadEd25519(
payload: string | Buffer,
privateKeyHex: string,
): string {
try {
const payloadBuffer = typeof payload === "string" ? Buffer.from(payload) : payload;
const privateKeyBuffer = Buffer.from(privateKeyHex, "hex");

// Reconstruct PKCS8 format from raw 32-byte seed
// Ed25519 PKCS8 structure: version (1 byte) + algorithm (15 bytes) + seed (32 bytes)
const pkcs8 = Buffer.concat([
Buffer.from([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]),
privateKeyBuffer,
]);

const privateKey = createPrivateKey({
key: pkcs8,
format: "der",
type: "pkcs8",
});

const signature = sign(null, payloadBuffer, privateKey);
return signature.toString("base64");
} catch (err) {
throw new Error(`Failed to sign payload with Ed25519: ${err}`);
}
}

/**
* Verify an Ed25519 signature.
* @param payload - The original payload (string or Buffer)
* @param signatureBase64 - The signature in base64 format
* @param publicKeyHex - The Ed25519 public key in hex format (32 bytes)
* @returns true if signature is valid, false otherwise
*/
export function verifySignatureEd25519(
payload: string | Buffer,
signatureBase64: string,
publicKeyHex: string,
): boolean {
try {
const payloadBuffer = typeof payload === "string" ? Buffer.from(payload) : payload;
const signatureBuffer = Buffer.from(signatureBase64, "base64");
const publicKeyBuffer = Buffer.from(publicKeyHex, "hex");

// Verify signature length (Ed25519 signatures are always 64 bytes)
if (signatureBuffer.length !== 64) {
return false;
}

// Reconstruct SubjectPublicKeyInfo (SPKI) format from raw 32-byte key
// Ed25519 SPKI structure: (12-byte header) + key (32 bytes)
const spki = Buffer.concat([
Buffer.from([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]),
publicKeyBuffer,
]);

const publicKey = createPublicKey({
key: spki,
format: "der",
type: "spki",
});

return verify(null, payloadBuffer, publicKey, signatureBuffer);
} catch (err) {
// Verification errors return false rather than throwing
return false;
}
}

/**
* Extract the public key from a private key (in hex format).
* @param privateKeyHex - The Ed25519 private key in hex format (32 bytes)
* @returns The public key in hex format (32 bytes)
*/
export function getPublicKeyFromPrivateEd25519(privateKeyHex: string): string {
try {
const privateKeyBuffer = Buffer.from(privateKeyHex, "hex");

// Reconstruct PKCS8 format from raw 32-byte seed
const pkcs8 = Buffer.concat([
Buffer.from([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]),
privateKeyBuffer,
]);

const privateKey = createPrivateKey({
key: pkcs8,
format: "der",
type: "pkcs8",
});

const publicKey = createPublicKey(privateKey);
// Export and extract the raw 32-byte public key from SPKI
const spki = publicKey.export({ format: "der", type: "spki" });
return spki.subarray(12).toString("hex");
} catch (err) {
throw new Error(`Failed to extract public key: ${err}`);
}
}
Loading
Loading