├── .gitignore ├── README.md ├── main.go ├── main_test.go └── urls.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Watchblob - WatchGuard VPN on Linux 2 | =================================== 3 | 4 | This tiny helper tool makes it possible to use WatchGuard / Firebox / <> VPNs that use multi-factor authentication on Linux. 6 | 7 | Rather than using OpenVPN's built-in dynamic challenge/response protocol, WatchGuard 8 | has opted for a separate implementation negotiating credentials outside of the 9 | OpenVPN protocol, which makes it impossible to start those connections solely by 10 | using the `openvpn` CLI and configuration files. 11 | 12 | What this application does has been reverse-engineered from the "WatchGuard Mobile VPN 13 | with SSL" application on OS X. 14 | 15 | I've published a [blog post](https://www.tazj.in/en/1486830338) describing the process 16 | and what is actually going on in this protocol. 17 | 18 | ## Installation 19 | 20 | Make sure you have Go installed and `GOPATH` configured, then simply 21 | `go get github.com/tazjin/watchblob/...`. 22 | 23 | ## Usage 24 | 25 | Right now the usage is very simple. Make sure you have the correct OpenVPN client 26 | config ready (this is normally supplied by the WatchGuard UI) simply run: 27 | 28 | ``` 29 | watchblob vpnserver.somedomain.org username p4ssw0rd 30 | ``` 31 | 32 | The server responds with a challenge which is displayed to the user, wait until you 33 | receive the SMS code or whatever and enter it. `watchblob` then completes the 34 | credential negotiation and you may proceed to log in with OpenVPN using your username 35 | and *the OTP token* (**not** your password) as credentials. 36 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/xml" 6 | "fmt" 7 | "golang.org/x/crypto/ssh/terminal" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "syscall" 12 | ) 13 | 14 | // The XML response returned by the WatchGuard server 15 | type Resp struct { 16 | Action string `xml:"action"` 17 | LogonStatus int `xml:"logon_status"` 18 | LogonId int `xml:"logon_id"` 19 | Error string `xml:"errStr"` 20 | Challenge string `xml:"chaStr"` 21 | } 22 | 23 | func main() { 24 | args := os.Args[1:] 25 | 26 | if len(args) != 1 { 27 | fmt.Fprintln(os.Stderr, "Usage: watchblob ") 28 | os.Exit(1) 29 | } 30 | 31 | host := args[0] 32 | 33 | username, password, err := readCredentials() 34 | if err != nil { 35 | fmt.Fprintf(os.Stderr, "Could not read credentials: %v\n", err) 36 | } 37 | 38 | fmt.Printf("Requesting challenge from %s as user %s\n", host, username) 39 | challenge, err := triggerChallengeResponse(&host, &username, &password) 40 | 41 | if err != nil || challenge.LogonStatus != 4 { 42 | fmt.Fprintln(os.Stderr, "Did not receive challenge from server") 43 | fmt.Fprintf(os.Stderr, "Response: %v\nError: %v\n", challenge, err) 44 | os.Exit(1) 45 | } 46 | 47 | token := getToken(&challenge) 48 | err = logon(&host, &challenge, &token) 49 | 50 | if err != nil { 51 | fmt.Fprintf(os.Stderr, "Logon failed: %v\n", err) 52 | os.Exit(1) 53 | } 54 | 55 | fmt.Printf("Login succeeded, you may now (quickly) authenticate OpenVPN with %s as your password\n", token) 56 | } 57 | 58 | func readCredentials() (string, string, error) { 59 | fmt.Printf("Username: ") 60 | reader := bufio.NewReader(os.Stdin) 61 | username, err := reader.ReadString('\n') 62 | 63 | fmt.Printf("Password: ") 64 | password, err := terminal.ReadPassword(syscall.Stdin) 65 | fmt.Println() 66 | 67 | // If an error occured, I don't care about which one it is. 68 | return strings.TrimSpace(username), strings.TrimSpace(string(password)), err 69 | } 70 | 71 | func triggerChallengeResponse(host *string, username *string, password *string) (r Resp, err error) { 72 | return request(templateUrl(host, templateChallengeTriggerUri(username, password))) 73 | } 74 | 75 | func getToken(challenge *Resp) string { 76 | fmt.Println(challenge.Challenge) 77 | 78 | reader := bufio.NewReader(os.Stdin) 79 | token, _ := reader.ReadString('\n') 80 | 81 | return strings.TrimSpace(token) 82 | } 83 | 84 | func logon(host *string, challenge *Resp, token *string) (err error) { 85 | resp, err := request(templateUrl(host, templateResponseUri(challenge.LogonId, token))) 86 | if err != nil { 87 | return 88 | } 89 | 90 | if resp.LogonStatus != 1 { 91 | err = fmt.Errorf("Challenge/response authentication failed: %v", resp) 92 | } 93 | 94 | return 95 | } 96 | 97 | func request(url string) (r Resp, err error) { 98 | resp, err := http.Get(url) 99 | if err != nil { 100 | return 101 | } 102 | 103 | defer resp.Body.Close() 104 | decoder := xml.NewDecoder(resp.Body) 105 | 106 | err = decoder.Decode(&r) 107 | return 108 | } 109 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/xml" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestUnmarshalChallengeRespones(t *testing.T) { 10 | var testXml string = ` 11 | 12 | 13 | sslvpn_logon 14 | 4 15 | 16 | 17 | RADIUS 18 | 19 | 20 | 441 21 | Enter Your 6 Digit Passcode 22 | ` 23 | 24 | var r Resp 25 | xml.Unmarshal([]byte(testXml), &r) 26 | 27 | expected := Resp{ 28 | Action: "sslvpn_logon", 29 | LogonStatus: 4, 30 | LogonId: 441, 31 | Challenge: "Enter Your 6 Digit Passcode ", 32 | } 33 | 34 | assertEqual(t, expected, r) 35 | } 36 | 37 | func TestUnmarshalLoginError(t *testing.T) { 38 | var testXml string = ` 39 | 40 | 41 | sslvpn_logon 42 | 2 43 | 44 | 45 | RADIUS 46 | 47 | 48 | 501 49 | ` 50 | 51 | var r Resp 52 | xml.Unmarshal([]byte(testXml), &r) 53 | 54 | expected := Resp{ 55 | Action: "sslvpn_logon", 56 | LogonStatus: 2, 57 | Error: "501", 58 | } 59 | 60 | assertEqual(t, expected, r) 61 | } 62 | 63 | func TestUnmarshalLoginSuccess(t *testing.T) { 64 | var testXml string = ` 65 | 66 | 67 | sslvpn_logon 68 | 1 69 | 70 | 71 | RADIUS 72 | 73 | 74 | 75 | ` 76 | var r Resp 77 | xml.Unmarshal([]byte(testXml), &r) 78 | 79 | expected := Resp{ 80 | Action: "sslvpn_logon", 81 | LogonStatus: 1, 82 | } 83 | 84 | assertEqual(t, expected, r) 85 | } 86 | 87 | func assertEqual(t *testing.T, expected interface{}, result interface{}) { 88 | if !reflect.DeepEqual(expected, result) { 89 | t.Errorf( 90 | "Unmarshaled values did not match.\nExpected: %v\nResult: %v\n", 91 | expected, result, 92 | ) 93 | 94 | t.Fail() 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /urls.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | ) 8 | 9 | const urlFormat string = "https://%s%s" 10 | const uriFormat = "/?%s" 11 | 12 | func templateChallengeTriggerUri(username *string, password *string) string { 13 | v := url.Values{} 14 | v.Set("action", "sslvpn_logon") 15 | v.Set("style", "fw_logon_progress.xsl") 16 | v.Set("fw_logon_type", "logon") 17 | v.Set("fw_domain", "Firebox-DB") 18 | v.Set("fw_username", *username) 19 | v.Set("fw_password", *password) 20 | 21 | return fmt.Sprintf(uriFormat, v.Encode()) 22 | } 23 | 24 | func templateResponseUri(logonId int, token *string) string { 25 | v := url.Values{} 26 | v.Set("action", "sslvpn_logon") 27 | v.Set("style", "fw_logon_progress.xsl") 28 | v.Set("fw_logon_type", "response") 29 | v.Set("response", *token) 30 | v.Set("fw_logon_id", strconv.Itoa(logonId)) 31 | 32 | return fmt.Sprintf(uriFormat, v.Encode()) 33 | } 34 | 35 | func templateUrl(baseUrl *string, uri string) string { 36 | return fmt.Sprintf(urlFormat, *baseUrl, uri) 37 | } 38 | --------------------------------------------------------------------------------