package stripe import ( "crypto/sha256" "encoding/base64" "fmt" "net/url" "regexp" "strings" "time" ) // SHA256URLSafe computes SHA-256 and returns URL-safe base64 (no padding). // Matches JS: btoa(sha256_raw(input)).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"") func SHA256URLSafe(input string) string { h := sha256.Sum256([]byte(input)) return base64.RawURLEncoding.EncodeToString(h[:]) } const urlSalt = "7766e861-8279-424d-87a1-07a6022fd8cd" // SHA256WithSalt hashes input with the Stripe URL salt. // Matches JS: sha256WithSalt which does sha256(unescape(encodeURIComponent(e)) + URL_SALT) func SHA256WithSalt(input string) string { if input == "" { return "" } // JS unescape(encodeURIComponent(s)) converts to UTF-8 bytes, which Go strings already are. return SHA256URLSafe(input + urlSalt) } // EncodeURIComponent mimics JavaScript's encodeURIComponent. func EncodeURIComponent(s string) string { result := url.QueryEscape(s) result = strings.ReplaceAll(result, "+", "%20") // JS encodeURIComponent does NOT encode: - _ . ! ~ * ' ( ) for _, c := range []string{"!", "'", "(", ")", "*", "~"} { result = strings.ReplaceAll(result, url.QueryEscape(c), c) } return result } // ---------- URL Hashing ---------- const ( defaultFullHashLimit = 10 totalPartsLimit = 40 partialHashLen = 6 pathPartsLimit = 30 ) func isStripeAuthority(s string) bool { if s == "//stripe.com" || s == "//stripe.com." { return true } return strings.HasSuffix(s, ".stripe.com") || strings.HasSuffix(s, ".stripe.com.") } func isStripeCheckoutAuthority(s string) bool { candidates := []string{ "//checkout.stripe.com", "//qa-checkout.stripe.com", "//edge-checkout.stripe.com", } for _, c := range candidates { if s == c { return true } } return false } // removeUserInfo strips user-info from an authority component. func removeUserInfo(e string) string { if e == "" { return e } t := strings.LastIndex(e, "@") if t == -1 { return e } return e[:2] + e[t+1:] // keep "//" prefix } // PartitionedUrl parses a URL into RFC-3986 components. type PartitionedUrl struct { Scheme string Authority string Path string Query string Fragment string } var urlParseRe = regexp.MustCompile(`^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?`) // NewPartitionedUrl parses a URL string. func NewPartitionedUrl(rawURL string) *PartitionedUrl { p := &PartitionedUrl{} if rawURL == "" { return p } m := urlParseRe.FindStringSubmatch(rawURL) if m == nil { return p } p.Scheme = m[1] // e.g. "https:" if m[3] != "" { p.Authority = removeUserInfo(m[3]) // e.g. "//chatgpt.com" } p.Path = m[5] // e.g. "/" p.Query = m[6] // e.g. "?foo=bar" p.Fragment = m[8] // e.g. "#section" return p } func (p *PartitionedUrl) String() string { var parts []string for _, s := range []string{p.Scheme, p.Authority, p.Path, p.Query, p.Fragment} { if s != "" { parts = append(parts, s) } } return strings.Join(parts, "") } // SequentialHashWithLimit manages individual segment hashing with full/partial limits. type SequentialHashWithLimit struct { s string cur int hashedCount int fullHashLimit int totalHashLimit int } func newSequentialHashWithLimit(s string, fullHashLimit, totalHashLimit int) *SequentialHashWithLimit { return &SequentialHashWithLimit{ s: s, cur: 0, hashedCount: 0, fullHashLimit: fullHashLimit, totalHashLimit: totalHashLimit, } } func (h *SequentialHashWithLimit) shouldHash() bool { return h.hashedCount < h.totalHashLimit } func (h *SequentialHashWithLimit) isLastHash() bool { return h.hashedCount == h.totalHashLimit-1 } func (h *SequentialHashWithLimit) shouldPartialHash() bool { return !h.isLastHash() && h.hashedCount >= h.fullHashLimit } func (h *SequentialHashWithLimit) replace(e string) { t := e n := strings.Index(h.s[h.cur:], e) if n == -1 { return } n += h.cur if h.isLastHash() { t = h.s[n:] } r := SHA256WithSalt(t) if h.shouldPartialHash() { if len(r) > partialHashLen { r = r[:partialHashLen] } } h.s = h.s[:n] + r + h.s[n+len(t):] h.cur = n + len(r) h.hashedCount++ } // SequentialSplitterAndHasher tracks remaining hash budget across URL parts. type SequentialSplitterAndHasher struct { remainingHashes int fullHashLimit int } func newSequentialSplitterAndHasher(fullHashLimit int) *SequentialSplitterAndHasher { return &SequentialSplitterAndHasher{ remainingHashes: totalPartsLimit, fullHashLimit: fullHashLimit, } } func (s *SequentialSplitterAndHasher) getFullHashLimit(part string) int { if part == "authority" { return totalPartsLimit } return s.fullHashLimit } func (s *SequentialSplitterAndHasher) totalHashLimitForPart(part string) int { switch part { case "authority": return totalPartsLimit case "path": v := s.remainingHashes if v > pathPartsLimit { v = pathPartsLimit } if v < 1 { v = 1 } return v case "query", "fragment": if s.remainingHashes < 1 { return 1 } return s.remainingHashes default: return 0 } } func (s *SequentialSplitterAndHasher) splitAndHash(input, partType string, delim *regexp.Regexp) string { if partType == "authority" && input != "" && isStripeCheckoutAuthority(input) { return input } if input == "" { return input } h := newSequentialHashWithLimit(input, s.getFullHashLimit(partType), s.totalHashLimitForPart(partType)) // Split by delimiter, filter empty strings segments := delim.Split(input, -1) for _, seg := range segments { if seg == "" { continue } if h.shouldHash() { h.replace(seg) } } s.remainingHashes -= h.hashedCount return h.s } // hashURL hashes URL components using SequentialSplitterAndHasher. func hashURL(p *PartitionedUrl, fullHashLimit int) *PartitionedUrl { r := newSequentialSplitterAndHasher(fullHashLimit) authorityRe := regexp.MustCompile(`[/.:]`) otherRe := regexp.MustCompile(`[/#?!&+,=]`) p.Authority = r.splitAndHash(p.Authority, "authority", authorityRe) p.Path = r.splitAndHash(p.Path, "path", otherRe) p.Query = r.splitAndHash(p.Query, "query", otherRe) p.Fragment = r.splitAndHash(p.Fragment, "fragment", otherRe) return p } // hashURLWithAuthorityCheck applies Stripe-specific authority logic before hashing. func hashURLWithAuthorityCheck(p *PartitionedUrl, fullHashLimit int) *PartitionedUrl { n := p.Authority if n != "" && isStripeCheckoutAuthority(n) { return hashURL(p, defaultFullHashLimit) } if n != "" && isStripeAuthority(n) { return p // don't hash stripe.com URLs } return hashURL(p, fullHashLimit) } // HashURL is the public entry point for URL hashing. func HashURL(urlStr string) string { p := NewPartitionedUrl(urlStr) return hashURLWithAuthorityCheck(p, defaultFullHashLimit).String() } // GetHashTimestampWithSalt returns "timestamp:hash" string. func GetHashTimestampWithSalt(randomValue string) string { now := time.Now().UnixMilli() hash := SHA256URLSafe(randomValue + fmt.Sprintf("%d", now+1)) return fmt.Sprintf("%d:%s", now, hash) }