Skip to content

Commit

Permalink
Merge pull request #84 from SenseUnit/hmac_auth
Browse files Browse the repository at this point in the history
HMAC authorization
  • Loading branch information
Snawoot authored Nov 26, 2024
2 parents 67d898d + 88153cf commit b3b7a60
Show file tree
Hide file tree
Showing 6 changed files with 248 additions and 23 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ Authentication parameters are passed as URI via `-auth` parameter. Scheme of URI
* `path` - location of file with login and password pairs. File format is similar to htpasswd files. Each line must be in form `<username>:<bcrypt hash of password>`. Empty lines and lines starting with `#` are ignored.
* `hidden_domain` - same as in `static` provider
* `reload` - interval for conditional password file reload, if it was modified since last load. Use negative duration to disable autoreload. Default: `15s`.
* `hmac` - authentication with HMAC-signatures passed as username and password via basic authentication scheme. In that scheme username represents user login as usual and password should be constructed as follows: *password := urlsafe\_base64\_without\_padding(expire\_timestamp || hmac\_sha256(secret, "dumbproxy grant token v1" || username || expire\_timestamp))*, where *expire_timestamp* is 64-bit big-endian UNIX timestamp and *||* is a concatenation operator. [This Python script](https://gist.github.com/Snawoot/2b5acc232680d830f0f308f14e540f1d) can be used a reference implementation of signing.
* `secret` - hex-encoded HMAC secret key. Alternatively it can be specified by `DUMBPROXY_HMAC_SECRET` environment variable. Secret key can be generated with command like this: `openssl rand -hex 32`.
* `hidden_domain` - same as in `static` provider
* `cert` - use mutual TLS authentication with client certificates. In order to use this auth provider server must listen sockert in TLS mode (`-cert` and `-key` options) and client CA file must be specified (`-cacert`). Example: `cert://`.
* `blacklist` - location of file with list of serial numbers of blocked certificates, one per each line in form of hex-encoded colon-separated bytes. Example: `ab:01:02:03`. Empty lines and comments starting with `#` are ignored.
* `reload` - interval for certificate blacklist file reload, if it was modified since last load. Use negative duration to disable autoreload. Default: `15s`.
Expand Down
2 changes: 2 additions & 0 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ func NewAuth(paramstr string, logger *clog.CondLogger) (Auth, error) {
return NewStaticAuth(url, logger)
case "basicfile":
return NewBasicFileAuth(url, logger)
case "hmac":
return NewHMACAuth(url, logger)
case "cert":
return NewCertAuth(url, logger)
case "none":
Expand Down
23 changes: 0 additions & 23 deletions auth/basic.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
package auth

import (
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
Expand Down Expand Up @@ -116,27 +114,6 @@ func (auth *BasicAuth) reloadLoop(interval time.Duration) {
}
}

func matchHiddenDomain(host, hidden_domain string) bool {
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.ToLower(host)
return subtle.ConstantTimeCompare([]byte(host), []byte(hidden_domain)) == 1
}

func requireBasicAuth(wr http.ResponseWriter, req *http.Request, hidden_domain string) {
if hidden_domain != "" &&
!matchHiddenDomain(req.URL.Host, hidden_domain) &&
!matchHiddenDomain(req.Host, hidden_domain) {
http.Error(wr, BAD_REQ_MSG, http.StatusBadRequest)
} else {
wr.Header().Set("Proxy-Authenticate", `Basic realm="dumbproxy"`)
wr.Header().Set("Content-Length", strconv.Itoa(len([]byte(AUTH_REQUIRED_MSG))))
wr.WriteHeader(407)
wr.Write([]byte(AUTH_REQUIRED_MSG))
}
}

func (auth *BasicAuth) Validate(wr http.ResponseWriter, req *http.Request) (string, bool) {
hdr := req.Header.Get("Proxy-Authorization")
if hdr == "" {
Expand Down
30 changes: 30 additions & 0 deletions auth/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package auth

import (
"crypto/subtle"
"net"
"net/http"
"strconv"
"strings"
)

func matchHiddenDomain(host, hidden_domain string) bool {
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.ToLower(host)
return subtle.ConstantTimeCompare([]byte(host), []byte(hidden_domain)) == 1
}

func requireBasicAuth(wr http.ResponseWriter, req *http.Request, hidden_domain string) {
if hidden_domain != "" &&
!matchHiddenDomain(req.URL.Host, hidden_domain) &&
!matchHiddenDomain(req.Host, hidden_domain) {
http.Error(wr, BAD_REQ_MSG, http.StatusBadRequest)
} else {
wr.Header().Set("Proxy-Authenticate", `Basic realm="dumbproxy"`)
wr.Header().Set("Content-Length", strconv.Itoa(len([]byte(AUTH_REQUIRED_MSG))))
wr.WriteHeader(407)
wr.Write([]byte(AUTH_REQUIRED_MSG))
}
}
142 changes: 142 additions & 0 deletions auth/hmac.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package auth

import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"

clog "github.com/SenseUnit/dumbproxy/log"
)

const (
HMACSignaturePrefix = "dumbproxy grant token v1"
HMACSignatureSize = 32
HMACTimestampSize = 8
EnvVarHMACSecret = "DUMBPROXY_HMAC_SECRET"
)

type HMACAuth struct {
secret []byte
hiddenDomain string
logger *clog.CondLogger
}

func NewHMACAuth(param_url *url.URL, logger *clog.CondLogger) (*HMACAuth, error) {
values, err := url.ParseQuery(param_url.RawQuery)
if err != nil {
return nil, err
}

hexSecret := os.Getenv(EnvVarHMACSecret)
if hs := values.Get("secret"); hs != "" {
hexSecret = hs
}

if hexSecret == "" {
return nil, errors.New("no HMAC secret specified. Please specify \"secret\" parameter for auth provider or set " + EnvVarHMACSecret + " environment variable.")
}

secret, err := hex.DecodeString(hexSecret)
if err != nil {
return nil, fmt.Errorf("can't hex-decode HMAC secret: %w", err)
}

return &HMACAuth{
secret: secret,
logger: logger,
hiddenDomain: strings.ToLower(values.Get("hidden_domain")),
}, nil
}

type HMACToken struct {
Expire int64
Signature [HMACSignatureSize]byte
}

func (auth *HMACAuth) validateToken(login, password string) bool {
marshaledToken, err := base64.RawURLEncoding.DecodeString(password)
if err != nil {
return false
}

var token HMACToken
_, err = binary.Decode(marshaledToken, binary.BigEndian, &token)
if err != nil {
return false
}

if time.Unix(token.Expire, 0).Before(time.Now()) {
return false
}

expectedMAC := CalculateHMACSignature(auth.secret, login, token.Expire)
return hmac.Equal(token.Signature[:], expectedMAC)
}

func (auth *HMACAuth) Validate(wr http.ResponseWriter, req *http.Request) (string, bool) {
hdr := req.Header.Get("Proxy-Authorization")
if hdr == "" {
requireBasicAuth(wr, req, auth.hiddenDomain)
return "", false
}
hdr_parts := strings.SplitN(hdr, " ", 2)
if len(hdr_parts) != 2 || strings.ToLower(hdr_parts[0]) != "basic" {
requireBasicAuth(wr, req, auth.hiddenDomain)
return "", false
}

token := hdr_parts[1]
data, err := base64.StdEncoding.DecodeString(token)
if err != nil {
requireBasicAuth(wr, req, auth.hiddenDomain)
return "", false
}

pair := strings.SplitN(string(data), ":", 2)
if len(pair) != 2 {
requireBasicAuth(wr, req, auth.hiddenDomain)
return "", false
}

login := pair[0]
password := pair[1]

if auth.validateToken(login, password) {
if auth.hiddenDomain != "" &&
(req.Host == auth.hiddenDomain || req.URL.Host == auth.hiddenDomain) {
wr.Header().Set("Content-Length", strconv.Itoa(len([]byte(AUTH_TRIGGERED_MSG))))
wr.Header().Set("Pragma", "no-cache")
wr.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
wr.Header().Set("Expires", EPOCH_EXPIRE)
wr.Header()["Date"] = nil
wr.WriteHeader(http.StatusOK)
wr.Write([]byte(AUTH_TRIGGERED_MSG))
return "", false
} else {
return login, true
}
}
requireBasicAuth(wr, req, auth.hiddenDomain)
return "", false
}

func (auth *HMACAuth) Stop() {
}

func CalculateHMACSignature(secret []byte, username string, expire int64) []byte {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(HMACSignaturePrefix))
mac.Write([]byte(username))
binary.Write(mac, binary.BigEndian, expire)
return mac.Sum(nil)
}
71 changes: 71 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package main

import (
"bytes"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"flag"
"fmt"
Expand Down Expand Up @@ -139,6 +144,8 @@ type CLIArgs struct {
autocertHTTP string
passwd string
passwdCost int
hmacSign bool
hmacGenKey bool
positionalArgs []string
proxy []string
sourceIPHints string
Expand Down Expand Up @@ -173,6 +180,9 @@ func parse_args() CLIArgs {
flag.StringVar(&args.passwd, "passwd", "", "update given htpasswd file and add/set password for username. "+
"Username and password can be passed as positional arguments or requested interactively")
flag.IntVar(&args.passwdCost, "passwd-cost", bcrypt.MinCost, "bcrypt password cost (for -passwd mode)")
flag.BoolVar(&args.hmacSign, "hmac-sign", false, "sign username with specified key for given validity period. "+
"Positional arguments are: hex-encoded HMAC key, username, validity duration.")
flag.BoolVar(&args.hmacGenKey, "hmac-genkey", false, "generate hex-encoded HMAC signing key of optimal length")
flag.Func("proxy", "upstream proxy URL. Can be repeated multiple times to chain proxies. Examples: socks5h://127.0.0.1:9050; https://user:[email protected]:443", func(p string) error {
args.proxy = append(args.proxy, p)
return nil
Expand Down Expand Up @@ -206,6 +216,20 @@ func run() int {
return 0
}

if args.hmacSign {
if err := hmacSign(args.positionalArgs...); err != nil {
log.Fatalf("can't sign: %v", err)
}
return 0
}

if args.hmacGenKey {
if err := hmacGenKey(); err != nil {
log.Fatalf("can't generate key: %v", err)
}
return 0
}

logWriter := clog.NewLogWriter(os.Stderr)
defer logWriter.Close()

Expand Down Expand Up @@ -458,6 +482,53 @@ func passwd(filename string, cost int, args ...string) error {
return nil
}

func hmacSign(args ...string) error {
if len(args) != 3 {
fmt.Fprintln(os.Stderr, "Usage:")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "dumbproxy -hmac-sign <HMAC key> <username> <validity duration>")
fmt.Fprintln(os.Stderr, "")
return errors.New("bad command line arguments")
}

secret, err := hex.DecodeString(args[0])
if err != nil {
return fmt.Errorf("unable to hex-decode HMAC secret: %w", err)
}

validity, err := time.ParseDuration(args[2])
if err != nil {
return fmt.Errorf("unable to parse validity duration: %w", err)
}

expire := time.Now().Add(validity).Unix()
mac := auth.CalculateHMACSignature(secret, args[1], expire)
token := auth.HMACToken{
Expire: expire,
}
copy(token.Signature[:], mac)

var resBuf bytes.Buffer
enc := base64.NewEncoder(base64.RawURLEncoding, &resBuf)
if err := binary.Write(enc, binary.BigEndian, &token); err != nil {
return fmt.Errorf("token encoding failed: %w", err)
}
enc.Close()

fmt.Println("Username:", args[1])
fmt.Println("Password:", resBuf.String())
return nil
}

func hmacGenKey(args ...string) error {
buf := make([]byte, auth.HMACSignatureSize)
if _, err := rand.Read(buf); err != nil {
return fmt.Errorf("CSPRNG failure: %w", err)
}
fmt.Println(hex.EncodeToString(buf))
return nil
}

func prompt(prompt string, secure bool) (string, error) {
var input string
fmt.Print(prompt)
Expand Down

0 comments on commit b3b7a60

Please sign in to comment.