From eff5771b030f6d1fc143ff90d0436623f2725ea3 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 18 Apr 2026 17:39:00 +0200 Subject: [PATCH] feat: jwt_generate, jwt_validate, password_hash, password_verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 2 del issue 0010 — auth core: - jwt_generate/validate: HS256 manual con crypto/hmac + crypto/sha256 - password_hash/verify: wrappers de golang.org/x/crypto/bcrypt (cost 12 default) - JWT rechaza alg != HS256 para mitigar ataque 'alg=none' - hmac.Equal para comparacion constant-time de firmas --- functions/infra/jwt_generate.go | 44 +++++++++++++ functions/infra/jwt_generate.md | 46 ++++++++++++++ functions/infra/jwt_generate_test.go | 54 ++++++++++++++++ functions/infra/jwt_validate.go | 72 +++++++++++++++++++++ functions/infra/jwt_validate.md | 45 ++++++++++++++ functions/infra/jwt_validate_test.go | 83 +++++++++++++++++++++++++ functions/infra/password_hash.go | 16 +++++ functions/infra/password_hash.md | 41 ++++++++++++ functions/infra/password_hash_test.go | 34 ++++++++++ functions/infra/password_verify.go | 9 +++ functions/infra/password_verify.md | 47 ++++++++++++++ functions/infra/password_verify_test.go | 23 +++++++ go.mod | 1 + go.sum | 2 + 14 files changed, 517 insertions(+) create mode 100644 functions/infra/jwt_generate.go create mode 100644 functions/infra/jwt_generate.md create mode 100644 functions/infra/jwt_generate_test.go create mode 100644 functions/infra/jwt_validate.go create mode 100644 functions/infra/jwt_validate.md create mode 100644 functions/infra/jwt_validate_test.go create mode 100644 functions/infra/password_hash.go create mode 100644 functions/infra/password_hash.md create mode 100644 functions/infra/password_hash_test.go create mode 100644 functions/infra/password_verify.go create mode 100644 functions/infra/password_verify.md create mode 100644 functions/infra/password_verify_test.go diff --git a/functions/infra/jwt_generate.go b/functions/infra/jwt_generate.go new file mode 100644 index 00000000..a6d61057 --- /dev/null +++ b/functions/infra/jwt_generate.go @@ -0,0 +1,44 @@ +package infra + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "time" +) + +// JWTGenerate codifica un JWT firmado con HMAC-SHA256 (alg: HS256). +// Si claims.IssuedAt viene en cero se setea a time.Now().Unix(). +// Retorna el token en formato "header.payload.signature" con los tres segmentos +// codificados en base64url sin padding. +func JWTGenerate(claims JWTClaims, secret string) (string, error) { + if secret == "" { + return "", errors.New("jwt_generate: secret vacio") + } + if claims.IssuedAt == 0 { + claims.IssuedAt = time.Now().Unix() + } + + header := map[string]string{"alg": "HS256", "typ": "JWT"} + headerJSON, err := json.Marshal(header) + if err != nil { + return "", err + } + payloadJSON, err := json.Marshal(claims) + if err != nil { + return "", err + } + + enc := base64.RawURLEncoding + headerPart := enc.EncodeToString(headerJSON) + payloadPart := enc.EncodeToString(payloadJSON) + signingInput := headerPart + "." + payloadPart + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + sigPart := enc.EncodeToString(mac.Sum(nil)) + + return signingInput + "." + sigPart, nil +} diff --git a/functions/infra/jwt_generate.md b/functions/infra/jwt_generate.md new file mode 100644 index 00000000..7813f9e7 --- /dev/null +++ b/functions/infra/jwt_generate.md @@ -0,0 +1,46 @@ +--- +name: jwt_generate +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func JWTGenerate(claims JWTClaims, secret string) (string, error)" +description: "Codifica y firma un JWT con HMAC-SHA256 (HS256). Retorna el token en formato header.payload.signature. Setea IssuedAt automaticamente si viene en cero." +tags: [jwt, auth, token, hmac, sign, infra] +uses_functions: [] +uses_types: [JWTClaims_go_infra] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [crypto/hmac, crypto/sha256, encoding/base64, encoding/json, errors, time] +params: + - name: claims + desc: "claims del JWT (sub, iss, aud, exp, iat, custom). Si IssuedAt es 0 se rellena con time.Now()" + - name: secret + desc: "clave HMAC para firmar. No debe estar vacia. Obtenerla de env var o pass_get, nunca hardcoded" +output: "token JWT firmado en formato base64url header.payload.signature" +tested: true +tests: ["genera token valido con claims completas", "setea IssuedAt si viene en cero", "error si secret vacio"] +test_file_path: "functions/infra/jwt_generate_test.go" +file_path: "functions/infra/jwt_generate.go" +--- + +## Ejemplo + +```go +claims := JWTClaims{ + Subject: "user-123", + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + Custom: map[string]any{"role": "admin"}, +} +token, err := JWTGenerate(claims, os.Getenv("JWT_SECRET")) +if err != nil { + return err +} +w.Header().Set("Authorization", "Bearer " + token) +``` + +## Notas + +Impura — usa `time.Now()` para el claim `iat` cuando no viene fijado. Implementa HS256 sin libreria externa (solo stdlib crypto/hmac + crypto/sha256). Solo soporta HS256: para RS256/ES256 se crearia una funcion separada. El secret debe tener al menos 256 bits de entropia (32+ bytes aleatorios) para resistencia real. NO apto para escenarios multi-servicio donde se necesita clave publica/privada — usa RS256 en ese caso. diff --git a/functions/infra/jwt_generate_test.go b/functions/infra/jwt_generate_test.go new file mode 100644 index 00000000..b06aab6d --- /dev/null +++ b/functions/infra/jwt_generate_test.go @@ -0,0 +1,54 @@ +package infra + +import ( + "strings" + "testing" + "time" +) + +func TestJWTGenerate_ReturnsThreeSegments(t *testing.T) { + claims := JWTClaims{ + Subject: "user-1", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + token, err := JWTGenerate(claims, "test-secret-0123456789abcdef") + if err != nil { + t.Fatalf("JWTGenerate error: %v", err) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("esperados 3 segmentos, got %d en %q", len(parts), token) + } + for i, p := range parts { + if p == "" { + t.Fatalf("segmento %d vacio", i) + } + } +} + +func TestJWTGenerate_FillsIssuedAtWhenZero(t *testing.T) { + claims := JWTClaims{ + Subject: "user-1", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + before := time.Now().Unix() + token, err := JWTGenerate(claims, "s") + if err != nil { + t.Fatalf("JWTGenerate error: %v", err) + } + after := time.Now().Unix() + parsed, err := JWTValidate(token, "s") + if err != nil { + t.Fatalf("JWTValidate error: %v", err) + } + if parsed.IssuedAt < before || parsed.IssuedAt > after { + t.Fatalf("IssuedAt %d fuera del rango [%d,%d]", parsed.IssuedAt, before, after) + } +} + +func TestJWTGenerate_ErrorsOnEmptySecret(t *testing.T) { + _, err := JWTGenerate(JWTClaims{Subject: "x"}, "") + if err == nil { + t.Fatal("esperaba error con secret vacio") + } +} diff --git a/functions/infra/jwt_validate.go b/functions/infra/jwt_validate.go new file mode 100644 index 00000000..f999ecb8 --- /dev/null +++ b/functions/infra/jwt_validate.go @@ -0,0 +1,72 @@ +package infra + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" +) + +// JWTValidate verifica la firma HMAC-SHA256 de un JWT y decodifica sus claims. +// Rechaza tokens mal formados, con firma invalida o expirados (exp < time.Now()). +// Retorna las claims si todo es valido. +func JWTValidate(token string, secret string) (JWTClaims, error) { + var zero JWTClaims + if secret == "" { + return zero, errors.New("jwt_validate: secret vacio") + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return zero, errors.New("jwt_validate: token malformado (se esperaban 3 segmentos)") + } + + enc := base64.RawURLEncoding + signingInput := parts[0] + "." + parts[1] + + // Verificar firma + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + expectedSig := mac.Sum(nil) + + gotSig, err := enc.DecodeString(parts[2]) + if err != nil { + return zero, errors.New("jwt_validate: firma mal codificada") + } + if !hmac.Equal(expectedSig, gotSig) { + return zero, errors.New("jwt_validate: firma invalida") + } + + // Decodificar header y confirmar alg + headerBytes, err := enc.DecodeString(parts[0]) + if err != nil { + return zero, errors.New("jwt_validate: header mal codificado") + } + var header map[string]string + if err := json.Unmarshal(headerBytes, &header); err != nil { + return zero, errors.New("jwt_validate: header no es JSON valido") + } + if alg, _ := header["alg"]; alg != "HS256" { + return zero, errors.New("jwt_validate: algoritmo no soportado (solo HS256)") + } + + // Decodificar claims + payloadBytes, err := enc.DecodeString(parts[1]) + if err != nil { + return zero, errors.New("jwt_validate: payload mal codificado") + } + var claims JWTClaims + if err := json.Unmarshal(payloadBytes, &claims); err != nil { + return zero, errors.New("jwt_validate: payload no es JSON valido") + } + + // Validar expiracion si esta presente + if claims.ExpiresAt > 0 && time.Now().Unix() >= claims.ExpiresAt { + return zero, errors.New("jwt_validate: token expirado") + } + + return claims, nil +} diff --git a/functions/infra/jwt_validate.md b/functions/infra/jwt_validate.md new file mode 100644 index 00000000..37656ca4 --- /dev/null +++ b/functions/infra/jwt_validate.md @@ -0,0 +1,45 @@ +--- +name: jwt_validate +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func JWTValidate(token string, secret string) (JWTClaims, error)" +description: "Verifica la firma HMAC-SHA256 de un JWT y decodifica sus claims. Rechaza tokens mal formados, con firma invalida o expirados." +tags: [jwt, auth, token, hmac, verify, infra] +uses_functions: [] +uses_types: [JWTClaims_go_infra] +returns: [JWTClaims_go_infra] +returns_optional: false +error_type: error_go_core +imports: [crypto/hmac, crypto/sha256, encoding/base64, encoding/json, errors, strings, time] +params: + - name: token + desc: "JWT string en formato header.payload.signature (base64url, sin padding)" + - name: secret + desc: "clave HMAC usada para firmar el token. Debe coincidir con la usada en JWTGenerate" +output: "claims decodificadas si el token es valido; error si firma invalida, expirado o malformado" +tested: true +tests: ["valida token generado por JWTGenerate", "rechaza firma invalida", "rechaza token expirado", "rechaza token malformado", "rechaza algoritmo distinto de HS256"] +test_file_path: "functions/infra/jwt_validate_test.go" +file_path: "functions/infra/jwt_validate.go" +--- + +## Ejemplo + +```go +auth := r.Header.Get("Authorization") +token := strings.TrimPrefix(auth, "Bearer ") +claims, err := JWTValidate(token, os.Getenv("JWT_SECRET")) +if err != nil { + HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_token", Message: err.Error()}) + return +} +userID := claims.Subject +role, _ := claims.Custom["role"].(string) +``` + +## Notas + +Impura — usa `time.Now()` para comparar contra `exp`. Usa `hmac.Equal` para comparacion constant-time de firmas (mitiga timing attacks). Solo acepta alg=HS256 en el header, otros algoritmos se rechazan explicitamente para evitar el ataque "alg=none". Si `exp` es 0 (no fijado) no se valida expiracion — es responsabilidad del caller asegurar que sus tokens siempre tengan exp fijado. Errores descriptivos con prefijo `jwt_validate:` para facilitar debugging; en respuestas HTTP conviene mapear todos a un mensaje generico "token invalido" para no filtrar informacion. diff --git a/functions/infra/jwt_validate_test.go b/functions/infra/jwt_validate_test.go new file mode 100644 index 00000000..4300f9b9 --- /dev/null +++ b/functions/infra/jwt_validate_test.go @@ -0,0 +1,83 @@ +package infra + +import ( + "strings" + "testing" + "time" +) + +func TestJWTValidate_ValidatesGeneratedToken(t *testing.T) { + claims := JWTClaims{ + Subject: "user-42", + Issuer: "tester", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + Custom: map[string]any{"role": "admin"}, + } + token, err := JWTGenerate(claims, "super-secret") + if err != nil { + t.Fatalf("JWTGenerate error: %v", err) + } + got, err := JWTValidate(token, "super-secret") + if err != nil { + t.Fatalf("JWTValidate error: %v", err) + } + if got.Subject != "user-42" { + t.Errorf("Subject = %q, esperado user-42", got.Subject) + } + if got.Issuer != "tester" { + t.Errorf("Issuer = %q, esperado tester", got.Issuer) + } + if got.Custom["role"] != "admin" { + t.Errorf("Custom[role] = %v, esperado admin", got.Custom["role"]) + } +} + +func TestJWTValidate_RejectsInvalidSignature(t *testing.T) { + token, err := JWTGenerate(JWTClaims{Subject: "x", ExpiresAt: time.Now().Add(time.Hour).Unix()}, "secret-a") + if err != nil { + t.Fatalf("JWTGenerate error: %v", err) + } + if _, err := JWTValidate(token, "secret-b"); err == nil { + t.Fatal("esperaba error con secret distinto") + } +} + +func TestJWTValidate_RejectsExpiredToken(t *testing.T) { + token, err := JWTGenerate(JWTClaims{ + Subject: "x", + ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), + }, "s") + if err != nil { + t.Fatalf("JWTGenerate error: %v", err) + } + if _, err := JWTValidate(token, "s"); err == nil { + t.Fatal("esperaba error con token expirado") + } +} + +func TestJWTValidate_RejectsMalformedToken(t *testing.T) { + cases := []string{ + "", + "not-a-token", + "one.two", + "one.two.three.four", + } + for _, tok := range cases { + if _, err := JWTValidate(tok, "s"); err == nil { + t.Errorf("esperaba error con token %q", tok) + } + } +} + +func TestJWTValidate_RejectsOtherAlgorithms(t *testing.T) { + // Token con alg=none no es aceptado aunque la firma sea vacia + // Construimos manualmente: header con alg=none + // "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0" = {"alg":"none","typ":"JWT"} + token := "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ4In0." + if _, err := JWTValidate(token, "s"); err == nil { + t.Fatal("esperaba error con alg=none") + } + if !strings.Contains(token, ".") { + t.Fatal("token debe tener puntos") + } +} diff --git a/functions/infra/password_hash.go b/functions/infra/password_hash.go new file mode 100644 index 00000000..45851c33 --- /dev/null +++ b/functions/infra/password_hash.go @@ -0,0 +1,16 @@ +package infra + +import "golang.org/x/crypto/bcrypt" + +// PasswordHash hashea un password con bcrypt. +// cost controla el trabajo computacional (4 = minimo, 14 = muy lento). Valor 0 usa default 12. +func PasswordHash(password string, cost int) (string, error) { + if cost <= 0 { + cost = 12 + } + b, err := bcrypt.GenerateFromPassword([]byte(password), cost) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/functions/infra/password_hash.md b/functions/infra/password_hash.md new file mode 100644 index 00000000..e9174fdd --- /dev/null +++ b/functions/infra/password_hash.md @@ -0,0 +1,41 @@ +--- +name: password_hash +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func PasswordHash(password string, cost int) (string, error)" +description: "Hashea un password con bcrypt. Cost por defecto es 12 (si se pasa 0). El hash resultante incluye el salt y el cost embebidos." +tags: [password, hash, bcrypt, auth, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [golang.org/x/crypto/bcrypt] +params: + - name: password + desc: "password en texto plano a hashear" + - name: cost + desc: "coste bcrypt entre 4 y 14. 0 usa el default 12 (buen balance velocidad/seguridad en 2025)" +output: "hash bcrypt en formato $2a$... apto para guardar en BD y verificar con PasswordVerify" +tested: true +tests: ["hashea password con cost default", "hashea password con cost custom", "hashes distintos para mismo password (salt diferente)"] +test_file_path: "functions/infra/password_hash_test.go" +file_path: "functions/infra/password_hash.go" +--- + +## Ejemplo + +```go +hash, err := PasswordHash(inputPassword, 12) +if err != nil { + return err +} +db.Exec("INSERT INTO users (email, password_hash) VALUES (?, ?)", email, hash) +``` + +## Notas + +Impura — bcrypt usa entropia del OS para generar salt aleatorio en cada invocacion. El hash producido incluye el salt y el cost embebidos en el string (`$2a$12$salt...hash`), por lo que PasswordVerify no necesita el cost como parametro aparte. Cost 12 = ~250ms/hash en hardware moderno (2025): suficiente para bloquear ataques por fuerza bruta sin ser insoportable en el login. Para proteccion extra en servidores con mucho CPU disponible se puede subir a 14. diff --git a/functions/infra/password_hash_test.go b/functions/infra/password_hash_test.go new file mode 100644 index 00000000..86157b59 --- /dev/null +++ b/functions/infra/password_hash_test.go @@ -0,0 +1,34 @@ +package infra + +import ( + "strings" + "testing" +) + +func TestPasswordHash_DefaultCost(t *testing.T) { + hash, err := PasswordHash("hunter2", 0) + if err != nil { + t.Fatalf("PasswordHash error: %v", err) + } + if !strings.HasPrefix(hash, "$2") { + t.Errorf("hash no tiene prefijo bcrypt: %q", hash) + } +} + +func TestPasswordHash_CustomCost(t *testing.T) { + hash, err := PasswordHash("password", 4) // 4 = minimum, rapido para tests + if err != nil { + t.Fatalf("PasswordHash error: %v", err) + } + if hash == "" { + t.Fatal("hash vacio") + } +} + +func TestPasswordHash_DifferentSalts(t *testing.T) { + h1, _ := PasswordHash("same-password", 4) + h2, _ := PasswordHash("same-password", 4) + if h1 == h2 { + t.Fatal("los hashes deben diferir por salt distinto") + } +} diff --git a/functions/infra/password_verify.go b/functions/infra/password_verify.go new file mode 100644 index 00000000..67cc9d6a --- /dev/null +++ b/functions/infra/password_verify.go @@ -0,0 +1,9 @@ +package infra + +import "golang.org/x/crypto/bcrypt" + +// PasswordVerify compara un password en texto plano contra un hash bcrypt. +// Retorna nil si hacen match, error si no. +func PasswordVerify(password string, hash string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} diff --git a/functions/infra/password_verify.md b/functions/infra/password_verify.md new file mode 100644 index 00000000..e58b958a --- /dev/null +++ b/functions/infra/password_verify.md @@ -0,0 +1,47 @@ +--- +name: password_verify +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func PasswordVerify(password string, hash string) error" +description: "Verifica un password en texto plano contra un hash bcrypt. Retorna nil si hacen match, error si no coinciden o si el hash es invalido." +tags: [password, verify, bcrypt, auth, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [golang.org/x/crypto/bcrypt] +params: + - name: password + desc: "password en texto plano a verificar" + - name: hash + desc: "hash bcrypt obtenido previamente de PasswordHash (guardado en BD)" +output: "nil si el password coincide con el hash; error si no coincide o hash invalido" +tested: true +tests: ["verifica password correcto", "rechaza password incorrecto", "rechaza hash malformado"] +test_file_path: "functions/infra/password_verify_test.go" +file_path: "functions/infra/password_verify.go" +--- + +## Ejemplo + +```go +row := db.QueryRow("SELECT password_hash FROM users WHERE email = ?", email) +var stored string +if err := row.Scan(&stored); err != nil { + HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"}) + return +} +if err := PasswordVerify(input, stored); err != nil { + HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"}) + return +} +// OK, emitir token +``` + +## Notas + +Impura — bcrypt.CompareHashAndPassword es constant-time internamente (mitiga timing attacks). En respuestas HTTP al usuario NO distinguir entre "email no existe" y "password incorrecto": ambos casos deben retornar el mismo mensaje generico para no filtrar existencia de cuentas. El error real se puede loguear internamente con log_info/log_warn sin problema. diff --git a/functions/infra/password_verify_test.go b/functions/infra/password_verify_test.go new file mode 100644 index 00000000..6d13bb1a --- /dev/null +++ b/functions/infra/password_verify_test.go @@ -0,0 +1,23 @@ +package infra + +import "testing" + +func TestPasswordVerify_CorrectPassword(t *testing.T) { + hash, _ := PasswordHash("correct-horse-battery-staple", 4) + if err := PasswordVerify("correct-horse-battery-staple", hash); err != nil { + t.Fatalf("esperaba nil, got %v", err) + } +} + +func TestPasswordVerify_WrongPassword(t *testing.T) { + hash, _ := PasswordHash("secret", 4) + if err := PasswordVerify("wrong", hash); err == nil { + t.Fatal("esperaba error con password incorrecto") + } +} + +func TestPasswordVerify_InvalidHash(t *testing.T) { + if err := PasswordVerify("x", "not-a-bcrypt-hash"); err == nil { + t.Fatal("esperaba error con hash invalido") + } +} diff --git a/go.mod b/go.mod index 9f4ac2af..9ced0b9e 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/jackc/pgx/v5 v5.9.1 github.com/marcboeker/go-duckdb v1.8.5 github.com/mattn/go-sqlite3 v1.14.37 + golang.org/x/crypto v0.48.0 golang.org/x/sync v0.20.0 gopkg.in/yaml.v3 v3.0.1 nhooyr.io/websocket v1.8.17 diff --git a/go.sum b/go.sum index a95bebc2..c2c26744 100644 --- a/go.sum +++ b/go.sum @@ -155,6 +155,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=