package api import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "time" "github.com/google/uuid" "github.com/redis/go-redis/v9" ) // RSVP-edit nonce store. When a guest revisits their invitation from a // device that doesn't match the one they originally responded from, we // hide their RSVP details from the access response so a forwarded link // can't leak (or alter) their reply. They can request a one-time edit // link sent to their email/SMS; that link carries a short-lived nonce // stored here. // // We store sha256(raw) so a database snapshot of Redis doesn't give an // attacker working nonces; the raw value only ever lives in the email // link the guest receives. const ( editNonceTTL = 30 * time.Minute editNonceKeyPrefix = "gg:rsvp_edit:" ) type editNonceStore struct { rdb *redis.Client } // newEditNonceStore returns nil when Redis isn't configured — the calling // handler treats nil as "edit-link flow disabled" and falls back to the // strict same-device rule. func newEditNonceStore(rdb *redis.Client) *editNonceStore { if rdb == nil { return nil } return &editNonceStore{rdb: rdb} } // Mint creates a fresh nonce bound to guestID, stores its hash with a // 30-minute TTL, and returns the raw value for embedding in the email // link. Hex-encoded so it survives URL transit without further escaping. func (s *editNonceStore) Mint(ctx context.Context, guestID uuid.UUID) (raw string, err error) { if s == nil { return "", errors.New("edit nonce store not configured") } buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { return "", err } raw = hex.EncodeToString(buf) sum := sha256.Sum256([]byte(raw)) key := editNonceKeyPrefix + hex.EncodeToString(sum[:]) if err := s.rdb.Set(ctx, key, guestID.String(), editNonceTTL).Err(); err != nil { return "", err } return raw, nil } // Verify reports whether raw is a still-valid nonce for guestID. // Doesn't consume the nonce; expiry happens naturally via TTL so the // guest can refresh the edit page or briefly close + reopen the link // during the half-hour window without losing their session. func (s *editNonceStore) Verify(ctx context.Context, raw string, guestID uuid.UUID) (bool, error) { if s == nil || raw == "" { return false, nil } sum := sha256.Sum256([]byte(raw)) key := editNonceKeyPrefix + hex.EncodeToString(sum[:]) val, err := s.rdb.Get(ctx, key).Result() if err != nil { if errors.Is(err, redis.Nil) { return false, nil } return false, err } return val == guestID.String(), nil } // fingerprintsSimilar reports whether two device fingerprints look like // the same browser. The check is intentionally narrow: just `user_agent`, // because it's the strongest signal of "same browser" and the one least // affected by normal day-to-day variation (changing networks, switching // rooms, etc). // // Conservative-by-default: when either side lacks a user_agent string we // return false, which causes the access handler to hide the RSVP. The // legitimate guest in that edge case can still recover via the // request-edit-link flow. // // Two fingerprints with the same user_agent but different IPs are // considered the same device. This is on purpose: a guest jumping // between Wi-Fi and mobile data is the same person on the same phone. // The Gate's full scoring (run on submit/edit, not on access) catches // the more sophisticated mismatches. func fingerprintsSimilar(stored, current map[string]any) bool { if stored == nil || current == nil { return false } s, _ := stored["user_agent"].(string) c, _ := current["user_agent"].(string) if s == "" || c == "" { return false } return s == c }