package securechat import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "regexp" "strconv" "strings" "time" ) // Firmas HMAC-SHA256 (sdk-node/src/webhook.js, backend/src/lib/signing.js): // // Webhook (SecureChat → tú): HMAC(secret, ".") // Petición (tú → /v1): HMAC(secret, "...") // Cabecera: X-SecureChat-Signature: t=,v1= // // La clave HMAC es el webhookSecret completo como texto (incluido el prefijo "whsec_"). const DefaultToleranceSeconds = 300 // SignatureError: SIGNATURE_MISSING, SIGNATURE_MALFORMED, SIGNATURE_EXPIRED, SIGNATURE_INVALID, SECRET_MISSING. type SignatureError struct { Code string Msg string } func (e *SignatureError) Error() string { return e.Code + ": " + e.Msg } var ( tRe = regexp.MustCompile(`^[0-9]{1,15}$`) v1Re = regexp.MustCompile(`^[0-9a-f]{64}$`) ) // ParseSignatureHeader extrae t y v1 de "t=,v1=". func ParseSignatureHeader(header string) (int64, string, error) { if header == "" { return 0, "", &SignatureError{"SIGNATURE_MISSING", "falta la cabecera X-SecureChat-Signature"} } parts := map[string]string{} for _, p := range strings.Split(header, ",") { if i := strings.IndexByte(p, '='); i > 0 { parts[strings.TrimSpace(p[:i])] = strings.TrimSpace(p[i+1:]) } } if len(header) > 512 || !tRe.MatchString(parts["t"]) || !v1Re.MatchString(parts["v1"]) { return 0, "", &SignatureError{"SIGNATURE_MALFORMED", "cabecera de firma con formato inválido"} } t, _ := strconv.ParseInt(parts["t"], 10, 64) return t, parts["v1"], nil } func hmacHex(secret string, parts ...[]byte) string { m := hmac.New(sha256.New, []byte(secret)) for _, p := range parts { m.Write(p) } return hex.EncodeToString(m.Sum(nil)) } func safeEqualHex(a, b string) bool { ba, err1 := hex.DecodeString(a) bb, err2 := hex.DecodeString(b) return err1 == nil && err2 == nil && hmac.Equal(ba, bb) } // VerifyWebhook verifica la firma sobre los bytes CRUDOS del cuerpo. now == 0 → reloj actual. func VerifyWebhook(rawBody []byte, signatureHeader, secret string, toleranceSeconds int64, now int64) error { if secret == "" { return &SignatureError{"SECRET_MISSING", "falta el webhookSecret"} } t, v1, err := ParseSignatureHeader(signatureHeader) if err != nil { return err } if now == 0 { now = time.Now().Unix() } if d := now - t; d > toleranceSeconds || -d > toleranceSeconds { return &SignatureError{"SIGNATURE_EXPIRED", "timestamp fuera de la ventana permitida (posible replay)"} } if !safeEqualHex(hmacHex(secret, []byte(strconv.FormatInt(t, 10)+"."), rawBody), v1) { return &SignatureError{"SIGNATURE_INVALID", "la firma no coincide"} } return nil } // SignRequest devuelve la cabecera X-SecureChat-Signature de una petición a /v1. // pathAndQuery es exactamente lo que va en la URL tras el host (p. ej. "/v1/conversations?limit=20"). func SignRequest(method, pathAndQuery string, rawBody []byte, secret string, timestamp int64) string { if timestamp == 0 { timestamp = time.Now().Unix() } ts := strconv.FormatInt(timestamp, 10) return "t=" + ts + ",v1=" + hmacHex(secret, []byte(ts+"."+strings.ToUpper(method)+"."+pathAndQuery+"."), rawBody) } // SignWebhook: firma saliente (la usa SecureChat; aquí solo para pruebas). func SignWebhook(rawBody []byte, secret string, timestamp int64) string { ts := strconv.FormatInt(timestamp, 10) return "t=" + ts + ",v1=" + hmacHex(secret, []byte(ts+"."), rawBody) }