// Receptor de webhooks de SecureChat con net/http. // // SECURECHAT_WEBHOOK_SECRET=whsec_… SECURECHAT_KEYS_DIR=/ruta/segura/keys \ // SECURECHAT_API_URL=https://api.… SECURECHAT_API_KEY=sk_live_… \ // go run ./cmd/webhook # escucha en :8080 (ADDR para cambiarlo) // // Qué hace: // - verifica la firma sobre el cuerpo CRUDO y descarta eventos repetidos (por eventId) // - message.created → descifra y responde "Recibido: " cifrado // - conversation.started → verifica la vinculación con el secreto del link y, en una // reinvitación verificada, resella la clave para el dispositivo nuevo (rewrap) // // En producción: responde 2xx rápido y procesa en una cola (SecureChat corta a los 10 s), // guarda eventId e inviteSecret en tu base de datos y sirve esto por HTTPS. package main import ( "context" "encoding/json" "io" "log" "net/http" "os" "path/filepath" "sync" "time" "example.com/securechat-go/securechat" ) func main() { secret := os.Getenv("SECURECHAT_WEBHOOK_SECRET") keysDir := envOr("SECURECHAT_KEYS_DIR", "./keys") stateDir := envOr("SECURECHAT_STATE_DIR", filepath.Join(os.TempDir(), "securechat-go-demo")) if secret == "" { log.Fatal("falta SECURECHAT_WEBHOOK_SECRET") } // Llave actual primero y, si rotaste, las anteriores (private.v1.key / public.v1.key…). companyKeys, err := securechat.LoadAllCompanyKeys(keysDir) if err != nil { log.Fatal(err) } api := securechat.NewClient(os.Getenv("SECURECHAT_API_URL"), os.Getenv("SECURECHAT_API_KEY"), secret) var seen sync.Map // demo: en producción, una tabla con eventId único http.HandleFunc("POST /webhook", func(w http.ResponseWriter, r *http.Request) { // 1. Cuerpo CRUDO (nunca re-serialices el JSON antes de verificar). raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { http.Error(w, "body", http.StatusBadRequest) return } if err := securechat.VerifyWebhook(raw, r.Header.Get("X-SecureChat-Signature"), secret, securechat.DefaultToleranceSeconds, 0); err != nil { log.Printf("firma rechazada: %v", err) http.Error(w, securechat.ErrorCode(err), http.StatusUnauthorized) return } var ev securechat.Event if err := json.Unmarshal(raw, &ev); err != nil || ev.EventID == "" { http.Error(w, "event", http.StatusBadRequest) return } // 2. Idempotencia: la entrega es "al menos una vez". if _, dup := seen.LoadOrStore(ev.EventID, true); dup { w.WriteHeader(http.StatusOK) return } ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() if err := handle(ctx, ev, companyKeys, api, stateDir); err != nil { if securechat.ErrorCode(err) != "" { // Mensaje manipulado o llave equivocada: nunca lo trates como texto. 2xx: reintentar no lo arregla. log.Printf("[%s] %v", ev.ConversationID, err) w.WriteHeader(http.StatusOK) return } seen.Delete(ev.EventID) // error transitorio (red, API): que SecureChat reintente log.Printf("[%s] error: %v", ev.ConversationID, err) http.Error(w, "INTERNAL", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) }) addr := envOr("ADDR", ":8080") log.Printf("escuchando en %s (POST /webhook)", addr) srv := &http.Server{Addr: addr, ReadHeaderTimeout: 5 * time.Second} log.Fatal(srv.ListenAndServe()) } func handle(ctx context.Context, ev securechat.Event, keys []securechat.KeyPair, api *securechat.Client, stateDir string) error { switch ev.EventType { case "message.created": var d securechat.MessageCreated if err := json.Unmarshal(ev.Data, &d); err != nil { return err } if d.ContentType != "text" { return nil // adjuntos: ver docs/13 §5 } text, k, kv, err := securechat.DecryptEvent(ev, keys) if err != nil { return err } log.Printf("[%s] cliente: %s", ev.ConversationID, text) reply, err := securechat.EncryptReply(k, ev.ConversationID, kv, "Recibido: "+text) if err != nil { return err } return api.SendMessage(ctx, ev.ConversationID, reply, "reply-"+ev.EventID) case "conversation.started": var d securechat.ConversationStarted if err := json.Unmarshal(ev.Data, &d); err != nil { return err } verified := false // El inviteSecret lo guardaste al crear la invitación (aquí: un JSON de demo). if s := inviteSecretFor(stateDir, ev.ConversationID); s != "" && d.InviteBinding != nil { secret, err := securechat.B64uDecode(s) if err != nil { return err } userPk, err := securechat.B64uDecode(d.UserPublicKey) if err != nil { return err } // BINDING_INVALID: alguien sustituyó la llave del usuario. if err := securechat.VerifyInviteBinding(*d.InviteBinding, secret, ev.ConversationID, userPk, d.ConversationKey); err != nil { return err } verified = true } if d.IsReinvite { if !verified { log.Printf("[%s] reinvitación sin vinculación: no reselló (confírmalo con el cliente)", ev.ConversationID) return nil } k, err := securechat.OpenWithAnyKey(d.ConversationKey, ev.ConversationID, keys) if err != nil { return err } userPk, _ := securechat.B64uDecode(d.UserPublicKey) wrap, err := securechat.WrapConversationKey(k, ev.ConversationID, d.ConversationKey.KeyVersion, userPk) if err != nil { return err } if err := api.Rewrap(ctx, ev.ConversationID, wrap); err != nil { return err } } log.Printf("[%s] conversación iniciada · verificada=%v", ev.ConversationID, verified) } return nil // tolera eventos nuevos } func inviteSecretFor(stateDir, conversationID string) string { raw, err := os.ReadFile(filepath.Join(stateDir, "invite-secrets.json")) if err != nil { return "" } var m map[string]string _ = json.Unmarshal(raw, &m) return m[conversationID] } func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def }