Files
goget/internal/auth/digest.go
T

207 lines
5.7 KiB
Go

// Package auth handles HTTP authentication schemes (Basic, Digest, OAuth 2.0).
//go:build linux || freebsd
// +build linux freebsd
package auth
import (
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
)
// DigestAuthHandler handles HTTP Digest Authentication
type DigestAuthHandler struct {
username string
password string
}
// NewDigestAuthHandler creates new handler
func NewDigestAuthHandler(username, password string) *DigestAuthHandler {
return &DigestAuthHandler{username, password}
}
// HandleResponse processes a 401 response and returns a modified request
func (h *DigestAuthHandler) HandleResponse(req *http.Request, resp *http.Response) (*http.Request, error) {
// Parse WWW-Authenticate header
authHeader := resp.Header.Get("WWW-Authenticate")
if !strings.HasPrefix(authHeader, "Digest ") {
return nil, fmt.Errorf("not a digest auth challenge")
}
// Parse digest parameters
params := parseDigestParams(authHeader[7:])
// Validate required parameters
requiredParams := []string{"realm", "nonce"}
for _, param := range requiredParams {
if _, exists := params[param]; !exists {
return nil, fmt.Errorf("missing required digest parameter: %s", param)
}
}
// Calculate response hash according to RFC 2617 / RFC 7616
qop := params.get("qop")
nonceCount := params.get("nc")
cnonce := params.get("cnonce")
opaque := params.get("opaque")
// HA1 = MD5(username:realm:password) or MD5(username:realm:password):nonce:cnonce
ha1 := h.calculateHA1(params["realm"], params["nonce"], cnonce, qop)
// HA2 = MD5(method:uri) or MD5(method:uri:response-body) for auth-int.
// RFC 7616 §3.4 (and RFC 2617 §3.2.2.3) require uri to be the
// request-uri — the path *and* the query string, exactly as it
// appears in the request line. Using req.URL.Path strips the
// query, which causes digest auth to fail for any URL that carries
// a token or other parameter in the query string.
ha2 := h.calculateHA2(req.Method, req.URL.RequestURI(), qop)
// Response calculation based on qop
var response string
if qop == "auth" || qop == "auth-int" {
response = md5Sum(ha1 + ":" + params["nonce"] + ":" + nonceCount + ":" + cnonce + ":" + qop + ":" + ha2)
} else {
// No qop (old RFC 2617 style)
response = md5Sum(ha1 + ":" + params["nonce"] + ":" + ha2)
}
// Build Authorization header — see note above about request-uri
// vs path.
auth := h.buildAuthHeader(params, req.URL.RequestURI(), response, nonceCount, cnonce, qop, opaque)
// Clone request and add header
newReq := req.Clone(req.Context())
newReq.Header.Set("Authorization", auth)
return newReq, nil
}
func (h *DigestAuthHandler) calculateHA1(realm, nonce, cnonce, qop string) string {
a1 := h.username + ":" + realm + ":" + h.password
ha1 := md5Sum(a1)
// If qop is specified, we need to include nonce and cnonce
if qop == "auth" || qop == "auth-int" {
ha1 = md5Sum(ha1 + ":" + nonce + ":" + cnonce)
}
return ha1
}
func (h *DigestAuthHandler) calculateHA2(method, uri, qop string) string {
a2 := method + ":" + uri
// For auth-int, we would need to include body hash, but that's complex
// For now, we just support auth and no-qop modes
return md5Sum(a2)
}
func (h *DigestAuthHandler) buildAuthHeader(params map[string]string, uri, response, nc, cnonce, qop, opaque string) string {
parts := []string{
fmt.Sprintf(`username="%s"`, h.username),
fmt.Sprintf(`realm="%s"`, params["realm"]),
fmt.Sprintf(`nonce="%s"`, params["nonce"]),
fmt.Sprintf(`uri="%s"`, uri),
fmt.Sprintf(`response="%s"`, response),
}
if qop != "" {
parts = append(parts,
fmt.Sprintf(`qop=%s`, qop),
fmt.Sprintf(`nc=%s`, nc),
fmt.Sprintf(`cnonce="%s"`, cnonce),
)
}
if opaque != "" {
parts = append(parts, fmt.Sprintf(`opaque="%s"`, opaque))
}
return "Digest " + strings.Join(parts, ", ")
}
// GenerateCnonce generates a cryptographically random client nonce
func GenerateCnonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback should never happen on a functioning system
// but we still produce a value rather than crash
for i := range b {
b[i] = byte(i)
}
}
return hex.EncodeToString(b)
}
// parseDigestParams parses the WWW-Authenticate header parameters
func parseDigestParams(header string) digestParams {
params := make(digestParams)
// Handle quoted and unquoted values
currentKey := ""
currentVal := ""
inQuotes := false
hasSeenEquals := false
for i := 0; i < len(header); i++ {
char := header[i]
switch {
case char == '=' && !inQuotes:
currentKey = strings.TrimSpace(currentKey)
currentVal = ""
hasSeenEquals = true
case char == '"' && (i == 0 || header[i-1] != '\\'):
inQuotes = !inQuotes
case char == ',' && !inQuotes:
if currentKey != "" {
params[currentKey] = strings.Trim(strings.TrimSpace(currentVal), `"`)
}
currentKey = ""
currentVal = ""
hasSeenEquals = false
case (char == ' ' || char == '\t') && !inQuotes && ((!hasSeenEquals && currentKey == "") || (hasSeenEquals && currentVal == "")):
// Skip leading whitespace before a key or after '='
default:
if !hasSeenEquals {
currentKey += string(char)
} else {
currentVal += string(char)
}
}
}
// Don't forget the last parameter
if currentKey != "" {
params[currentKey] = strings.Trim(strings.TrimSpace(currentVal), `"`)
}
return params
}
// digestParams is a map of digest authentication parameters
type digestParams map[string]string
func (p digestParams) get(key string) string {
if val, ok := p[key]; ok {
return val
}
return ""
}
func md5Sum(s string) string {
h := md5.Sum([]byte(s))
return hex.EncodeToString(h[:])
}
// SHA256 sum for future use (RFC 7616 supports SHA-256)
func sha256Sum(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}