From 7af07c596166bf93b79a9d0816b1950dde360fb9 Mon Sep 17 00:00:00 2001 From: jwijenbergh Date: Fri, 17 Jun 2022 14:00:40 +0200 Subject: Server: Implement function for checking renewal button visibility --- internal/api.go | 192 ------- internal/config.go | 59 -- internal/config/config.go | 60 ++ internal/discovery.go | 172 ------ internal/discovery/discovery.go | 177 ++++++ internal/fsm.go | 227 -------- internal/fsm/fsm.go | 228 ++++++++ internal/http.go | 183 ------- internal/http/http.go | 183 +++++++ internal/log.go | 77 --- internal/log/log.go | 78 +++ internal/oauth.go | 430 --------------- internal/oauth/oauth.go | 434 +++++++++++++++ internal/openvpn.go | 31 -- internal/server.go | 540 ------------------ internal/server/api.go | 221 ++++++++ internal/server/server.go | 605 +++++++++++++++++++++ internal/test_data/empty | 0 internal/test_data/generate.sh | 58 -- internal/test_data/generate_forged.py | 41 -- internal/test_data/organization_list.json | 1 - internal/test_data/organization_list.json.minisig | 4 - .../organization_list.json.tc_servlist.minisig | 4 - internal/test_data/other_list.json | 1 - internal/test_data/other_list.json.minisig | 4 - internal/test_data/public.key | 2 - internal/test_data/random.txt | 1 - internal/test_data/secret.key | 2 - internal/test_data/server_list.json | 3 - internal/test_data/server_list.json.blake2b | Bin 64 -> 0 bytes .../server_list.json.forged_keyid.minisig | 4 - .../test_data/server_list.json.forged_pure.minisig | 4 - .../test_data/server_list.json.large_time.minisig | 4 - internal/test_data/server_list.json.minisig | 4 - internal/test_data/server_list.json.pure.minisig | 4 - .../server_list.json.tc_earliertime.minisig | 4 - .../server_list.json.tc_emptyfile.minisig | 4 - .../server_list.json.tc_emptytime.minisig | 4 - .../server_list.json.tc_latertime.minisig | 4 - .../test_data/server_list.json.tc_nofile.minisig | 4 - .../test_data/server_list.json.tc_nohashed.minisig | 4 - .../test_data/server_list.json.tc_notime.minisig | 4 - .../test_data/server_list.json.tc_orglist.minisig | 4 - .../server_list.json.tc_otherfile.minisig | 4 - .../test_data/server_list.json.tc_random.minisig | 4 - .../test_data/server_list.json.wrong_key.minisig | 4 - internal/test_data/wrong_public.key | 2 - internal/test_data/wrong_secret.key | 2 - internal/util.go | 30 - internal/util/util.go | 30 + internal/verify.go | 205 ------- internal/verify/test_data/empty | 0 internal/verify/test_data/generate.sh | 58 ++ internal/verify/test_data/generate_forged.py | 41 ++ internal/verify/test_data/organization_list.json | 1 + .../test_data/organization_list.json.minisig | 4 + .../organization_list.json.tc_servlist.minisig | 4 + internal/verify/test_data/other_list.json | 1 + internal/verify/test_data/other_list.json.minisig | 4 + internal/verify/test_data/public.key | 2 + internal/verify/test_data/random.txt | 1 + internal/verify/test_data/secret.key | 2 + internal/verify/test_data/server_list.json | 3 + internal/verify/test_data/server_list.json.blake2b | Bin 0 -> 64 bytes .../server_list.json.forged_keyid.minisig | 4 + .../test_data/server_list.json.forged_pure.minisig | 4 + .../test_data/server_list.json.large_time.minisig | 4 + internal/verify/test_data/server_list.json.minisig | 4 + .../verify/test_data/server_list.json.pure.minisig | 4 + .../server_list.json.tc_earliertime.minisig | 4 + .../server_list.json.tc_emptyfile.minisig | 4 + .../server_list.json.tc_emptytime.minisig | 4 + .../server_list.json.tc_latertime.minisig | 4 + .../test_data/server_list.json.tc_nofile.minisig | 4 + .../test_data/server_list.json.tc_nohashed.minisig | 4 + .../test_data/server_list.json.tc_notime.minisig | 4 + .../test_data/server_list.json.tc_orglist.minisig | 4 + .../server_list.json.tc_otherfile.minisig | 4 + .../test_data/server_list.json.tc_random.minisig | 4 + .../test_data/server_list.json.wrong_key.minisig | 4 + internal/verify/test_data/wrong_public.key | 2 + internal/verify/test_data/wrong_secret.key | 2 + internal/verify/verify.go | 205 +++++++ internal/verify/verify_test.go | 140 +++++ internal/verify_test.go | 140 ----- internal/wireguard.go | 82 --- internal/wireguard/wireguard.go | 38 ++ 87 files changed, 2588 insertions(+), 2557 deletions(-) delete mode 100644 internal/api.go delete mode 100644 internal/config.go create mode 100644 internal/config/config.go delete mode 100644 internal/discovery.go create mode 100644 internal/discovery/discovery.go delete mode 100644 internal/fsm.go create mode 100644 internal/fsm/fsm.go delete mode 100644 internal/http.go create mode 100644 internal/http/http.go delete mode 100644 internal/log.go create mode 100644 internal/log/log.go delete mode 100644 internal/oauth.go create mode 100644 internal/oauth/oauth.go delete mode 100644 internal/openvpn.go delete mode 100644 internal/server.go create mode 100644 internal/server/api.go create mode 100644 internal/server/server.go delete mode 100644 internal/test_data/empty delete mode 100644 internal/test_data/generate.sh delete mode 100644 internal/test_data/generate_forged.py delete mode 100644 internal/test_data/organization_list.json delete mode 100644 internal/test_data/organization_list.json.minisig delete mode 100644 internal/test_data/organization_list.json.tc_servlist.minisig delete mode 100644 internal/test_data/other_list.json delete mode 100644 internal/test_data/other_list.json.minisig delete mode 100644 internal/test_data/public.key delete mode 100644 internal/test_data/random.txt delete mode 100644 internal/test_data/secret.key delete mode 100644 internal/test_data/server_list.json delete mode 100644 internal/test_data/server_list.json.blake2b delete mode 100644 internal/test_data/server_list.json.forged_keyid.minisig delete mode 100644 internal/test_data/server_list.json.forged_pure.minisig delete mode 100644 internal/test_data/server_list.json.large_time.minisig delete mode 100644 internal/test_data/server_list.json.minisig delete mode 100644 internal/test_data/server_list.json.pure.minisig delete mode 100644 internal/test_data/server_list.json.tc_earliertime.minisig delete mode 100644 internal/test_data/server_list.json.tc_emptyfile.minisig delete mode 100644 internal/test_data/server_list.json.tc_emptytime.minisig delete mode 100644 internal/test_data/server_list.json.tc_latertime.minisig delete mode 100644 internal/test_data/server_list.json.tc_nofile.minisig delete mode 100644 internal/test_data/server_list.json.tc_nohashed.minisig delete mode 100644 internal/test_data/server_list.json.tc_notime.minisig delete mode 100644 internal/test_data/server_list.json.tc_orglist.minisig delete mode 100644 internal/test_data/server_list.json.tc_otherfile.minisig delete mode 100644 internal/test_data/server_list.json.tc_random.minisig delete mode 100644 internal/test_data/server_list.json.wrong_key.minisig delete mode 100644 internal/test_data/wrong_public.key delete mode 100644 internal/test_data/wrong_secret.key delete mode 100644 internal/util.go create mode 100644 internal/util/util.go delete mode 100644 internal/verify.go create mode 100644 internal/verify/test_data/empty create mode 100644 internal/verify/test_data/generate.sh create mode 100644 internal/verify/test_data/generate_forged.py create mode 100644 internal/verify/test_data/organization_list.json create mode 100644 internal/verify/test_data/organization_list.json.minisig create mode 100644 internal/verify/test_data/organization_list.json.tc_servlist.minisig create mode 100644 internal/verify/test_data/other_list.json create mode 100644 internal/verify/test_data/other_list.json.minisig create mode 100644 internal/verify/test_data/public.key create mode 100644 internal/verify/test_data/random.txt create mode 100644 internal/verify/test_data/secret.key create mode 100644 internal/verify/test_data/server_list.json create mode 100644 internal/verify/test_data/server_list.json.blake2b create mode 100644 internal/verify/test_data/server_list.json.forged_keyid.minisig create mode 100644 internal/verify/test_data/server_list.json.forged_pure.minisig create mode 100644 internal/verify/test_data/server_list.json.large_time.minisig create mode 100644 internal/verify/test_data/server_list.json.minisig create mode 100644 internal/verify/test_data/server_list.json.pure.minisig create mode 100644 internal/verify/test_data/server_list.json.tc_earliertime.minisig create mode 100644 internal/verify/test_data/server_list.json.tc_emptyfile.minisig create mode 100644 internal/verify/test_data/server_list.json.tc_emptytime.minisig create mode 100644 internal/verify/test_data/server_list.json.tc_latertime.minisig create mode 100644 internal/verify/test_data/server_list.json.tc_nofile.minisig create mode 100644 internal/verify/test_data/server_list.json.tc_nohashed.minisig create mode 100644 internal/verify/test_data/server_list.json.tc_notime.minisig create mode 100644 internal/verify/test_data/server_list.json.tc_orglist.minisig create mode 100644 internal/verify/test_data/server_list.json.tc_otherfile.minisig create mode 100644 internal/verify/test_data/server_list.json.tc_random.minisig create mode 100644 internal/verify/test_data/server_list.json.wrong_key.minisig create mode 100644 internal/verify/test_data/wrong_public.key create mode 100644 internal/verify/test_data/wrong_secret.key create mode 100644 internal/verify/verify.go create mode 100644 internal/verify/verify_test.go delete mode 100644 internal/verify_test.go delete mode 100644 internal/wireguard.go create mode 100644 internal/wireguard/wireguard.go (limited to 'internal') diff --git a/internal/api.go b/internal/api.go deleted file mode 100644 index 5c2cf6d..0000000 --- a/internal/api.go +++ /dev/null @@ -1,192 +0,0 @@ -package internal - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" -) - -// Authorized wrappers on top of HTTP -// the errors will not be wrapped here so that the caller can check if we got a status error, to retry oauth -func apiAuthorized(server Server, method string, endpoint string, opts *HTTPOptionalParams) (http.Header, []byte, error) { - // Ensure optional is not nil as we will fill it with headers - if opts == nil { - opts = &HTTPOptionalParams{} - } - base, baseErr := server.GetBase() - - if baseErr != nil { - return nil, nil, baseErr - } - - url := base.Endpoints.API.V3.API + endpoint - - // Ensure we have valid tokens - stateBefore := base.FSM.Current - oauthErr := EnsureTokens(server) - - // we reset the state so that we go from the authorized state to the state we want - base.FSM.Current = stateBefore - - if oauthErr != nil { - return nil, nil, oauthErr - } - - headerKey := "Authorization" - headerValue := fmt.Sprintf("Bearer %s", server.GetOAuth().Token.Access) - if opts.Headers != nil { - opts.Headers.Add(headerKey, headerValue) - } else { - opts.Headers = http.Header{headerKey: {headerValue}} - } - return HTTPMethodWithOpts(method, url, opts) -} - -func apiAuthorizedRetry(server Server, method string, endpoint string, opts *HTTPOptionalParams) (http.Header, []byte, error) { - header, body, bodyErr := apiAuthorized(server, method, endpoint, opts) - base, baseErr := server.GetBase() - - if baseErr != nil { - return nil, nil, &APIAuthorizedError{Err: baseErr} - } - if bodyErr != nil { - var error *HTTPStatusError - - // Only retry authorized if we get a HTTP 401 - if errors.As(bodyErr, &error) && error.Status == 401 { - base.Logger.Log(LOG_INFO, fmt.Sprintf("API: Got HTTP error %v, retrying authorized", error)) - // Tell the method that the token is expired - server.GetOAuth().Token.ExpiredTimestamp = GenerateTimeSeconds() - retryHeader, retryBody, retryErr := apiAuthorized(server, method, endpoint, opts) - if retryErr != nil { - return nil, nil, &APIAuthorizedError{Err: retryErr} - } - return retryHeader, retryBody, nil - } - return nil, nil, &APIAuthorizedError{Err: bodyErr} - } - return header, body, nil -} - -func APIInfo(server Server) error { - _, body, bodyErr := apiAuthorizedRetry(server, http.MethodGet, "/info", nil) - if bodyErr != nil { - return &APIInfoError{Err: bodyErr} - } - structure := ServerProfileInfo{} - jsonErr := json.Unmarshal(body, &structure) - - if jsonErr != nil { - return &APIInfoError{Err: jsonErr} - } - - base, baseErr := server.GetBase() - - if baseErr != nil { - return &APIInfoError{Err: baseErr} - } - - // Store the profiles and make sure that the current profile is not overwritten - previousProfile := base.Profiles.Current - base.Profiles = structure - base.Profiles.Current = previousProfile - base.ProfilesRaw = string(body) - return nil -} - -func APIConnectWireguard(server Server, profile_id string, pubkey string, supportsOpenVPN bool) (string, string, int64, error) { - headers := http.Header{ - "content-type": {"application/x-www-form-urlencoded"}, - "accept": {"application/x-wireguard-profile"}, - } - - if supportsOpenVPN { - headers.Add("accept", "application/x-openvpn-profile") - } - - urlForm := url.Values{ - "profile_id": {profile_id}, - "public_key": {pubkey}, - } - header, connectBody, connectErr := apiAuthorizedRetry(server, http.MethodPost, "/connect", &HTTPOptionalParams{Headers: headers, Body: urlForm}) - if connectErr != nil { - return "", "", 0, &APIConnectWireguardError{Err: connectErr} - } - - expires := header.Get("expires") - - pTime, pTimeErr := http.ParseTime(expires) - if pTimeErr != nil { - return "", "", 0, &APIConnectWireguardError{Err: pTimeErr} - } - - contentType := header.Get("content-type") - - content := "openvpn" - if contentType == "application/x-wireguard-profile" { - content = "wireguard" - } - return string(connectBody), content, pTime.Unix(), nil -} - -func APIConnectOpenVPN(server Server, profile_id string) (string, int64, error) { - headers := http.Header{ - "content-type": {"application/x-www-form-urlencoded"}, - "accept": {"application/x-openvpn-profile"}, - } - - urlForm := url.Values{ - "profile_id": {profile_id}, - } - - header, connectBody, connectErr := apiAuthorizedRetry(server, http.MethodPost, "/connect", &HTTPOptionalParams{Headers: headers, Body: urlForm}) - if connectErr != nil { - return "", 0, &APIConnectOpenVPNError{Err: connectErr} - } - - expires := header.Get("expires") - pTime, pTimeErr := http.ParseTime(expires) - if pTimeErr != nil { - return "", 0, &APIConnectOpenVPNError{Err: pTimeErr} - } - return string(connectBody), pTime.Unix(), nil -} - -// This needs no further return value as it's best effort -func APIDisconnect(server Server) { - apiAuthorizedRetry(server, http.MethodPost, "/disconnect", nil) -} - -type APIAuthorizedError struct { - Err error -} - -func (e *APIAuthorizedError) Error() string { - return fmt.Sprintf("failed api authorized call with error: %v", e.Err) -} - -type APIConnectWireguardError struct { - Err error -} - -func (e *APIConnectWireguardError) Error() string { - return fmt.Sprintf("failed api /connect wireguard call with error: %v", e.Err) -} - -type APIConnectOpenVPNError struct { - Err error -} - -func (e *APIConnectOpenVPNError) Error() string { - return fmt.Sprintf("failed api /connect OpenVPN call with error: %v", e.Err) -} - -type APIInfoError struct { - Err error -} - -func (e *APIInfoError) Error() string { - return fmt.Sprintf("failed api /info call with error: %v", e.Err) -} diff --git a/internal/config.go b/internal/config.go deleted file mode 100644 index 0f13165..0000000 --- a/internal/config.go +++ /dev/null @@ -1,59 +0,0 @@ -package internal - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "path" -) - -type Config struct { - Name string - Directory string -} - -func (config *Config) Init(name string, directory string) { - config.Name = name - config.Directory = directory -} - -func (config *Config) GetFilename() string { - pathString := path.Join(config.Directory, config.Name) - return fmt.Sprintf("%s.json", pathString) -} - -func (config *Config) Save(readStruct interface{}) error { - configDirErr := EnsureDirectory(config.Directory) - if configDirErr != nil { - return &ConfigSaveError{Err: configDirErr} - } - jsonString, marshalErr := json.Marshal(readStruct) - if marshalErr != nil { - return &ConfigSaveError{Err: marshalErr} - } - return ioutil.WriteFile(config.GetFilename(), jsonString, 0o600) -} - -func (config *Config) Load(writeStruct interface{}) error { - bytes, readErr := ioutil.ReadFile(config.GetFilename()) - if readErr != nil { - return &ConfigLoadError{Err: readErr} - } - return json.Unmarshal(bytes, writeStruct) -} - -type ConfigSaveError struct { - Err error -} - -func (e *ConfigSaveError) Error() string { - return fmt.Sprintf("failed to save config with error: %v", e.Err) -} - -type ConfigLoadError struct { - Err error -} - -func (e *ConfigLoadError) Error() string { - return fmt.Sprintf("failed to load config with error: %v", e.Err) -} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a9ebec7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,60 @@ +package config + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "path" + "github.com/jwijenbergh/eduvpn-common/internal/util" +) + +type Config struct { + Name string + Directory string +} + +func (config *Config) Init(name string, directory string) { + config.Name = name + config.Directory = directory +} + +func (config *Config) GetFilename() string { + pathString := path.Join(config.Directory, config.Name) + return fmt.Sprintf("%s.json", pathString) +} + +func (config *Config) Save(readStruct interface{}) error { + configDirErr := util.EnsureDirectory(config.Directory) + if configDirErr != nil { + return &ConfigSaveError{Err: configDirErr} + } + jsonString, marshalErr := json.Marshal(readStruct) + if marshalErr != nil { + return &ConfigSaveError{Err: marshalErr} + } + return ioutil.WriteFile(config.GetFilename(), jsonString, 0o600) +} + +func (config *Config) Load(writeStruct interface{}) error { + bytes, readErr := ioutil.ReadFile(config.GetFilename()) + if readErr != nil { + return &ConfigLoadError{Err: readErr} + } + return json.Unmarshal(bytes, writeStruct) +} + +type ConfigSaveError struct { + Err error +} + +func (e *ConfigSaveError) Error() string { + return fmt.Sprintf("failed to save config with error: %v", e.Err) +} + +type ConfigLoadError struct { + Err error +} + +func (e *ConfigLoadError) Error() string { + return fmt.Sprintf("failed to load config with error: %v", e.Err) +} diff --git a/internal/discovery.go b/internal/discovery.go deleted file mode 100644 index 59281bd..0000000 --- a/internal/discovery.go +++ /dev/null @@ -1,172 +0,0 @@ -package internal - -import ( - "encoding/json" - "fmt" -) - -type DiscoFileError struct { - URL string - Err error -} - -func (e *DiscoFileError) Error() string { - return fmt.Sprintf("failed obtaining disco file %s with error %v", e.URL, e.Err) -} - -type DiscoSigFileError struct { - URL string - Err error -} - -func (e *DiscoSigFileError) Error() string { - return fmt.Sprintf("failed obtaining disco signature file %s with error %v", e.URL, e.Err) -} - -type DiscoVerifyError struct { - File string - Sigfile string - Err error -} - -func (e *DiscoVerifyError) Error() string { - return fmt.Sprintf("failed verifying file %s with signature %s due to error %v", e.File, e.Sigfile, e.Err) -} - -type DiscoJSONError struct { - Body string - Err error -} - -func (e *DiscoJSONError) Error() string { - return fmt.Sprintf("failed parsing JSON for contents %s with error %v", e.Body, e.Err) -} - -type OrganizationList struct { - JSON json.RawMessage `json:"organization_list"` - Version uint64 `json:"v"` - Timestamp int64 `json:"-"` -} - -type ServersList struct { - JSON json.RawMessage `json:"server_list"` - Version uint64 `json:"v"` - Timestamp int64 `json:"-"` -} - -type Discovery struct { - Organizations OrganizationList - Servers ServersList - FSM *FSM - Logger *FileLogger -} - -// Helper function that gets a disco json -func getDiscoFile(jsonFile string, previousVersion uint64, structure interface{}) error { - // Get json data - discoURL := "https://disco.eduvpn.org/v2/" - fileURL := discoURL + jsonFile - _, fileBody, fileErr := HTTPGet(fileURL) - - if fileErr != nil { - return &DiscoFileError{fileURL, fileErr} - } - - // Get signature - sigFile := jsonFile + ".minisig" - sigURL := discoURL + sigFile - _, sigBody, sigFileErr := HTTPGet(sigURL) - - if sigFileErr != nil { - return &DiscoSigFileError{URL: sigURL, Err: sigFileErr} - } - - // Verify signature - // Set this to true when we want to force prehash - forcePrehash := false - verifySuccess, verifyErr := Verify(string(sigBody), fileBody, jsonFile, previousVersion, forcePrehash) - - if !verifySuccess || verifyErr != nil { - return &DiscoVerifyError{File: jsonFile, Sigfile: sigFile, Err: verifyErr} - } - - // Parse JSON to extract version and list - jsonErr := json.Unmarshal(fileBody, structure) - - if jsonErr != nil { - return &DiscoJSONError{Body: string(fileBody), Err: jsonErr} - } - - return nil -} - -type GetListError struct { - File string - Err error -} - -func (e *GetListError) Error() string { - return fmt.Sprintf("failed getting disco list file %s with error %v", e.File, e.Err) -} - -func (discovery *Discovery) Init(fsm *FSM, logger *FileLogger) { - discovery.FSM = fsm - discovery.Logger = logger -} - -// FIXME: Implement based on -// https://github.com/eduvpn/documentation/blob/v3/SERVER_DISCOVERY.md -// - [IMPLEMENTED] on "first launch" when offering the search for "Institute Access" and "Organizations"; -// - [TODO] when the user tries to add new server AND the user did NOT yet choose an organization before; -// - [TODO] when the authorization for the server associated with an already chosen organization is triggered, e.g. after expiry or revocation. -func (discovery *Discovery) DetermineOrganizationsUpdate() bool { - return string(discovery.Organizations.JSON) == "" -} - -// https://github.com/eduvpn/documentation/blob/v3/SERVER_DISCOVERY.md -// - [Implemented] The application MUST always fetch the server_list.json at application start. -// - The application MAY refresh the server_list.json periodically, e.g. once every hour. -func (discovery *Discovery) DetermineServersUpdate() bool { - // No servers, we should update - if string(discovery.Servers.JSON) == "" { - return true - } - // 1 hour from the last update - should_update_time := discovery.Servers.Timestamp + 3600 - now := GenerateTimeSeconds() - if now >= should_update_time { - return true - } - discovery.Logger.Log(LOG_INFO, "No update needed for servers, 1h is not passed yet") - return false -} - -// Get the organization list -func (discovery *Discovery) GetOrganizationsList() (string, error) { - if !discovery.DetermineOrganizationsUpdate() { - return string(discovery.Organizations.JSON), nil - } - file := "organization_list.json" - err := getDiscoFile(file, discovery.Organizations.Version, &discovery.Organizations) - if err != nil { - // Return previous with an error - return string(discovery.Organizations.JSON), &GetListError{File: file, Err: err} - } - return string(discovery.Organizations.JSON), nil -} - -// Get the server list -func (discovery *Discovery) GetServersList() (string, error) { - if !discovery.DetermineServersUpdate() { - return string(discovery.Servers.JSON), nil - } - file := "server_list.json" - err := getDiscoFile(file, discovery.Servers.Version, &discovery.Servers) - if err != nil { - // Return previous with an error - return string(discovery.Servers.JSON), &GetListError{File: file, Err: err} - } - // Update servers timestamp - discovery.Servers.Timestamp = GenerateTimeSeconds() - return string(discovery.Servers.JSON), nil -} diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go new file mode 100644 index 0000000..d72b4a6 --- /dev/null +++ b/internal/discovery/discovery.go @@ -0,0 +1,177 @@ +package discovery + +import ( + "encoding/json" + "fmt" + "github.com/jwijenbergh/eduvpn-common/internal/fsm" + "github.com/jwijenbergh/eduvpn-common/internal/http" + "github.com/jwijenbergh/eduvpn-common/internal/log" + "github.com/jwijenbergh/eduvpn-common/internal/util" + "github.com/jwijenbergh/eduvpn-common/internal/verify" +) + +type DiscoFileError struct { + URL string + Err error +} + +func (e *DiscoFileError) Error() string { + return fmt.Sprintf("failed obtaining disco file %s with error %v", e.URL, e.Err) +} + +type DiscoSigFileError struct { + URL string + Err error +} + +func (e *DiscoSigFileError) Error() string { + return fmt.Sprintf("failed obtaining disco signature file %s with error %v", e.URL, e.Err) +} + +type DiscoVerifyError struct { + File string + Sigfile string + Err error +} + +func (e *DiscoVerifyError) Error() string { + return fmt.Sprintf("failed verifying file %s with signature %s due to error %v", e.File, e.Sigfile, e.Err) +} + +type DiscoJSONError struct { + Body string + Err error +} + +func (e *DiscoJSONError) Error() string { + return fmt.Sprintf("failed parsing JSON for contents %s with error %v", e.Body, e.Err) +} + +type OrganizationList struct { + JSON json.RawMessage `json:"organization_list"` + Version uint64 `json:"v"` + Timestamp int64 `json:"-"` +} + +type ServersList struct { + JSON json.RawMessage `json:"server_list"` + Version uint64 `json:"v"` + Timestamp int64 `json:"-"` +} + +type Discovery struct { + Organizations OrganizationList + Servers ServersList + FSM *fsm.FSM + Logger *log.FileLogger +} + +// Helper function that gets a disco json +func getDiscoFile(jsonFile string, previousVersion uint64, structure interface{}) error { + // Get json data + discoURL := "https://disco.eduvpn.org/v2/" + fileURL := discoURL + jsonFile + _, fileBody, fileErr := http.HTTPGet(fileURL) + + if fileErr != nil { + return &DiscoFileError{fileURL, fileErr} + } + + // Get signature + sigFile := jsonFile + ".minisig" + sigURL := discoURL + sigFile + _, sigBody, sigFileErr := http.HTTPGet(sigURL) + + if sigFileErr != nil { + return &DiscoSigFileError{URL: sigURL, Err: sigFileErr} + } + + // Verify signature + // Set this to true when we want to force prehash + forcePrehash := false + verifySuccess, verifyErr := verify.Verify(string(sigBody), fileBody, jsonFile, previousVersion, forcePrehash) + + if !verifySuccess || verifyErr != nil { + return &DiscoVerifyError{File: jsonFile, Sigfile: sigFile, Err: verifyErr} + } + + // Parse JSON to extract version and list + jsonErr := json.Unmarshal(fileBody, structure) + + if jsonErr != nil { + return &DiscoJSONError{Body: string(fileBody), Err: jsonErr} + } + + return nil +} + +type GetListError struct { + File string + Err error +} + +func (e *GetListError) Error() string { + return fmt.Sprintf("failed getting disco list file %s with error %v", e.File, e.Err) +} + +func (discovery *Discovery) Init(fsm *fsm.FSM, logger *log.FileLogger) { + discovery.FSM = fsm + discovery.Logger = logger +} + +// FIXME: Implement based on +// https://github.com/eduvpn/documentation/blob/v3/SERVER_DISCOVERY.md +// - [IMPLEMENTED] on "first launch" when offering the search for "Institute Access" and "Organizations"; +// - [TODO] when the user tries to add new server AND the user did NOT yet choose an organization before; +// - [TODO] when the authorization for the server associated with an already chosen organization is triggered, e.g. after expiry or revocation. +func (discovery *Discovery) DetermineOrganizationsUpdate() bool { + return string(discovery.Organizations.JSON) == "" +} + +// https://github.com/eduvpn/documentation/blob/v3/SERVER_DISCOVERY.md +// - [Implemented] The application MUST always fetch the server_list.json at application start. +// - The application MAY refresh the server_list.json periodically, e.g. once every hour. +func (discovery *Discovery) DetermineServersUpdate() bool { + // No servers, we should update + if string(discovery.Servers.JSON) == "" { + return true + } + // 1 hour from the last update + should_update_time := discovery.Servers.Timestamp + 3600 + now := util.GenerateTimeSeconds() + if now >= should_update_time { + return true + } + discovery.Logger.Log(log.LOG_INFO, "No update needed for servers, 1h is not passed yet") + return false +} + +// Get the organization list +func (discovery *Discovery) GetOrganizationsList() (string, error) { + if !discovery.DetermineOrganizationsUpdate() { + return string(discovery.Organizations.JSON), nil + } + file := "organization_list.json" + err := getDiscoFile(file, discovery.Organizations.Version, &discovery.Organizations) + if err != nil { + // Return previous with an error + return string(discovery.Organizations.JSON), &GetListError{File: file, Err: err} + } + return string(discovery.Organizations.JSON), nil +} + +// Get the server list +func (discovery *Discovery) GetServersList() (string, error) { + if !discovery.DetermineServersUpdate() { + return string(discovery.Servers.JSON), nil + } + file := "server_list.json" + err := getDiscoFile(file, discovery.Servers.Version, &discovery.Servers) + if err != nil { + // Return previous with an error + return string(discovery.Servers.JSON), &GetListError{File: file, Err: err} + } + // Update servers timestamp + discovery.Servers.Timestamp = util.GenerateTimeSeconds() + return string(discovery.Servers.JSON), nil +} diff --git a/internal/fsm.go b/internal/fsm.go deleted file mode 100644 index 4df24a0..0000000 --- a/internal/fsm.go +++ /dev/null @@ -1,227 +0,0 @@ -package internal - -import ( - "fmt" - "os" - "os/exec" - "path" - "sort" -) - -type ( - FSMStateID int8 - FSMStateIDSlice []FSMStateID -) - -func (v FSMStateIDSlice) Len() int { - return len(v) -} - -func (v FSMStateIDSlice) Less(i, j int) bool { - return v[i] < v[j] -} - -func (v FSMStateIDSlice) Swap(i, j int) { - v[i], v[j] = v[j], v[i] -} - -const ( - // Deregistered means the app is not registered with the wrapper - DEREGISTERED FSMStateID = iota - - // No Server means the user has not chosen a server yet - NO_SERVER - - // Chosen Server means the user has chosen a server to connect to - CHOSEN_SERVER - - // OAuth Started means the OAuth process has started - OAUTH_STARTED - - // Authorized means the OAuth process has finished and the user is now authorized with the server - AUTHORIZED - - // Requested config means the user has requested a config for connecting - REQUEST_CONFIG - - // Has config means the user has gotten a config - HAS_CONFIG - - // Ask profile means the go code is asking for a profile selection from the ui - ASK_PROFILE - - // Connected means the user has been connected to the server - CONNECTED -) - -func (s FSMStateID) String() string { - switch s { - case DEREGISTERED: - return "Deregistered" - case NO_SERVER: - return "No_Server" - case CHOSEN_SERVER: - return "Chosen_Server" - case OAUTH_STARTED: - return "OAuth_Started" - case HAS_CONFIG: - return "Has_Config" - case REQUEST_CONFIG: - return "Request_Config" - case ASK_PROFILE: - return "Ask_Profile" - case AUTHORIZED: - return "Authorized" - case CONNECTED: - return "Connected" - default: - panic("unknown conversion of state to string") - } -} - -type FSMTransition struct { - To FSMStateID - Description string -} - -type ( - FSMTransitions []FSMTransition - FSMStates map[FSMStateID]FSMTransitions -) - -type FSM struct { - States FSMStates - Current FSMStateID - - // Info to be passed from the parent state - Name string - StateCallback func(string, string, string) - Logger *FileLogger - Directory string - Debug bool -} - -func (fsm *FSM) Init(name string, callback func(string, string, string), logger *FileLogger, directory string, debug bool) { - fsm.States = FSMStates{ - DEREGISTERED: {{NO_SERVER, "Client registers"}}, - NO_SERVER: {{CHOSEN_SERVER, "User chooses a server"}}, - CHOSEN_SERVER: {{AUTHORIZED, "Found tokens in config"}, {OAUTH_STARTED, "No tokens found in config"}}, - OAUTH_STARTED: {{AUTHORIZED, "User authorizes with browser"}, {NO_SERVER, "Cancel or Error"}}, - AUTHORIZED: {{OAUTH_STARTED, "Re-authorize with OAuth"}, {REQUEST_CONFIG, "Client requests a config"}}, - REQUEST_CONFIG: {{ASK_PROFILE, "Multiple profiles found and no profile chosen"}, {HAS_CONFIG, "Only one profile or profile already chosen"}, {NO_SERVER, "Cancel or Error"}, {OAUTH_STARTED, "Re-authorize"}}, - ASK_PROFILE: {{HAS_CONFIG, "User chooses profile"}, {NO_SERVER, "Done but no profile selected"}}, - HAS_CONFIG: {{CONNECTED, "OS reports connected"}, {REQUEST_CONFIG, "User chooses a new profile"}, {NO_SERVER, "User wants to choose a new server"}}, - CONNECTED: {{HAS_CONFIG, "OS reports disconnected"}}, - } - fsm.Current = DEREGISTERED - fsm.Name = name - fsm.StateCallback = callback - fsm.Logger = logger - fsm.Directory = directory - fsm.Debug = debug -} - -func (fsm *FSM) InState(check FSMStateID) bool { - return check == fsm.Current -} - -func (fsm *FSM) HasTransition(check FSMStateID) bool { - for _, transition_state := range fsm.States[fsm.Current] { - if transition_state.To == check { - return true - } - } - - return false -} - -func (fsm *FSM) getGraphFilename(extension string) string { - debugPath := path.Join(fsm.Directory, fsm.Name) - return fmt.Sprintf("%s%s", debugPath, extension) -} - -func (fsm *FSM) writeGraph() { - graph := fsm.GenerateGraph() - graphFile := fsm.getGraphFilename(".graph") - graphImgFile := fsm.getGraphFilename(".png") - f, err := os.Create(graphFile) - if err != nil { - fsm.Logger.Log(LOG_INFO, fmt.Sprintf("Failed to write debug fsm graph with error %v", err)) - return - } - - f.WriteString(graph) - f.Close() - cmd := exec.Command("mmdc", "-i", graphFile, "-o", graphImgFile) - - cmd.Start() -} - -func (fsm *FSM) GoTransitionWithData(newState FSMStateID, data string, background bool) bool { - ok := fsm.HasTransition(newState) - - if ok { - oldState := fsm.Current - fsm.Current = newState - if fsm.Debug { - fsm.writeGraph() - } - - fsm.Logger.Log(LOG_INFO, fmt.Sprintf("State: %s -> State: %s with data %s\n", oldState.String(), newState.String(), data)) - - if background { - go fsm.StateCallback(oldState.String(), newState.String(), data) - } else { - fsm.StateCallback(oldState.String(), newState.String(), data) - } - } - - return ok -} - -func (fsm *FSM) GoTransition(newState FSMStateID) bool { - return fsm.GoTransitionWithData(newState, "", false) -} - -func (fsm *FSM) generateMermaidGraph() string { - graph := "graph TD\n" - sorted_fsm := make(FSMStateIDSlice, 0, len(fsm.States)) - for state_id := range fsm.States { - sorted_fsm = append(sorted_fsm, state_id) - } - sort.Sort(sorted_fsm) - for _, state := range sorted_fsm { - transitions := fsm.States[state] - for _, transition := range transitions { - if state == fsm.Current { - graph += "\nstyle " + state.String() + " fill:cyan\n" - } else { - graph += "\nstyle " + state.String() + " fill:white\n" - } - graph += state.String() + "(" + state.String() + ") " + "-->|" + transition.Description + "| " + transition.To.String() + "\n" - } - } - return graph -} - -func (fsm *FSM) GenerateGraph() string { - return fsm.generateMermaidGraph() -} - -type FSMWrongStateTransitionError struct { - Got FSMStateID - Want FSMStateID -} - -func (e *FSMWrongStateTransitionError) Error() string { - return fmt.Sprintf("wrong FSM state, got: %s, want a state with a transition to: %s", e.Got.String(), e.Want.String()) -} - -type FSMWrongStateError struct { - Got FSMStateID - Want FSMStateID -} - -func (e *FSMWrongStateError) Error() string { - return fmt.Sprintf("wrong FSM state, got: %s, want: %s", e.Got.String(), e.Want.String()) -} diff --git a/internal/fsm/fsm.go b/internal/fsm/fsm.go new file mode 100644 index 0000000..bb7f330 --- /dev/null +++ b/internal/fsm/fsm.go @@ -0,0 +1,228 @@ +package fsm + +import ( + "fmt" + "os" + "os/exec" + "path" + "sort" + "github.com/jwijenbergh/eduvpn-common/internal/log" +) + +type ( + FSMStateID int8 + FSMStateIDSlice []FSMStateID +) + +func (v FSMStateIDSlice) Len() int { + return len(v) +} + +func (v FSMStateIDSlice) Less(i, j int) bool { + return v[i] < v[j] +} + +func (v FSMStateIDSlice) Swap(i, j int) { + v[i], v[j] = v[j], v[i] +} + +const ( + // Deregistered means the app is not registered with the wrapper + DEREGISTERED FSMStateID = iota + + // No Server means the user has not chosen a server yet + NO_SERVER + + // Chosen Server means the user has chosen a server to connect to + CHOSEN_SERVER + + // OAuth Started means the OAuth process has started + OAUTH_STARTED + + // Authorized means the OAuth process has finished and the user is now authorized with the server + AUTHORIZED + + // Requested config means the user has requested a config for connecting + REQUEST_CONFIG + + // Has config means the user has gotten a config + HAS_CONFIG + + // Ask profile means the go code is asking for a profile selection from the ui + ASK_PROFILE + + // Connected means the user has been connected to the server + CONNECTED +) + +func (s FSMStateID) String() string { + switch s { + case DEREGISTERED: + return "Deregistered" + case NO_SERVER: + return "No_Server" + case CHOSEN_SERVER: + return "Chosen_Server" + case OAUTH_STARTED: + return "OAuth_Started" + case HAS_CONFIG: + return "Has_Config" + case REQUEST_CONFIG: + return "Request_Config" + case ASK_PROFILE: + return "Ask_Profile" + case AUTHORIZED: + return "Authorized" + case CONNECTED: + return "Connected" + default: + panic("unknown conversion of state to string") + } +} + +type FSMTransition struct { + To FSMStateID + Description string +} + +type ( + FSMTransitions []FSMTransition + FSMStates map[FSMStateID]FSMTransitions +) + +type FSM struct { + States FSMStates + Current FSMStateID + + // Info to be passed from the parent state + Name string + StateCallback func(string, string, string) + Logger *log.FileLogger + Directory string + Debug bool +} + +func (fsm *FSM) Init(name string, callback func(string, string, string), logger *log.FileLogger, directory string, debug bool) { + fsm.States = FSMStates{ + DEREGISTERED: {{NO_SERVER, "Client registers"}}, + NO_SERVER: {{CHOSEN_SERVER, "User chooses a server"}}, + CHOSEN_SERVER: {{AUTHORIZED, "Found tokens in config"}, {OAUTH_STARTED, "No tokens found in config"}}, + OAUTH_STARTED: {{AUTHORIZED, "User authorizes with browser"}, {NO_SERVER, "Cancel or Error"}}, + AUTHORIZED: {{OAUTH_STARTED, "Re-authorize with OAuth"}, {REQUEST_CONFIG, "Client requests a config"}}, + REQUEST_CONFIG: {{ASK_PROFILE, "Multiple profiles found and no profile chosen"}, {HAS_CONFIG, "Only one profile or profile already chosen"}, {NO_SERVER, "Cancel or Error"}, {OAUTH_STARTED, "Re-authorize"}}, + ASK_PROFILE: {{HAS_CONFIG, "User chooses profile"}, {NO_SERVER, "Done but no profile selected"}}, + HAS_CONFIG: {{CONNECTED, "OS reports connected"}, {REQUEST_CONFIG, "User chooses a new profile"}, {NO_SERVER, "User wants to choose a new server"}}, + CONNECTED: {{HAS_CONFIG, "OS reports disconnected"}}, + } + fsm.Current = DEREGISTERED + fsm.Name = name + fsm.StateCallback = callback + fsm.Logger = logger + fsm.Directory = directory + fsm.Debug = debug +} + +func (fsm *FSM) InState(check FSMStateID) bool { + return check == fsm.Current +} + +func (fsm *FSM) HasTransition(check FSMStateID) bool { + for _, transition_state := range fsm.States[fsm.Current] { + if transition_state.To == check { + return true + } + } + + return false +} + +func (fsm *FSM) getGraphFilename(extension string) string { + debugPath := path.Join(fsm.Directory, fsm.Name) + return fmt.Sprintf("%s%s", debugPath, extension) +} + +func (fsm *FSM) writeGraph() { + graph := fsm.GenerateGraph() + graphFile := fsm.getGraphFilename(".graph") + graphImgFile := fsm.getGraphFilename(".png") + f, err := os.Create(graphFile) + if err != nil { + fsm.Logger.Log(log.LOG_INFO, fmt.Sprintf("Failed to write debug fsm graph with error %v", err)) + return + } + + f.WriteString(graph) + f.Close() + cmd := exec.Command("mmdc", "-i", graphFile, "-o", graphImgFile) + + cmd.Start() +} + +func (fsm *FSM) GoTransitionWithData(newState FSMStateID, data string, background bool) bool { + ok := fsm.HasTransition(newState) + + if ok { + oldState := fsm.Current + fsm.Current = newState + if fsm.Debug { + fsm.writeGraph() + } + + fsm.Logger.Log(log.LOG_INFO, fmt.Sprintf("State: %s -> State: %s with data %s\n", oldState.String(), newState.String(), data)) + + if background { + go fsm.StateCallback(oldState.String(), newState.String(), data) + } else { + fsm.StateCallback(oldState.String(), newState.String(), data) + } + } + + return ok +} + +func (fsm *FSM) GoTransition(newState FSMStateID) bool { + return fsm.GoTransitionWithData(newState, "", false) +} + +func (fsm *FSM) generateMermaidGraph() string { + graph := "graph TD\n" + sorted_fsm := make(FSMStateIDSlice, 0, len(fsm.States)) + for state_id := range fsm.States { + sorted_fsm = append(sorted_fsm, state_id) + } + sort.Sort(sorted_fsm) + for _, state := range sorted_fsm { + transitions := fsm.States[state] + for _, transition := range transitions { + if state == fsm.Current { + graph += "\nstyle " + state.String() + " fill:cyan\n" + } else { + graph += "\nstyle " + state.String() + " fill:white\n" + } + graph += state.String() + "(" + state.String() + ") " + "-->|" + transition.Description + "| " + transition.To.String() + "\n" + } + } + return graph +} + +func (fsm *FSM) GenerateGraph() string { + return fsm.generateMermaidGraph() +} + +type FSMWrongStateTransitionError struct { + Got FSMStateID + Want FSMStateID +} + +func (e *FSMWrongStateTransitionError) Error() string { + return fmt.Sprintf("wrong FSM state, got: %s, want a state with a transition to: %s", e.Got.String(), e.Want.String()) +} + +type FSMWrongStateError struct { + Got FSMStateID + Want FSMStateID +} + +func (e *FSMWrongStateError) Error() string { + return fmt.Sprintf("wrong FSM state, got: %s, want: %s", e.Got.String(), e.Want.String()) +} diff --git a/internal/http.go b/internal/http.go deleted file mode 100644 index 0b1eda4..0000000 --- a/internal/http.go +++ /dev/null @@ -1,183 +0,0 @@ -package internal - -import ( - "fmt" - "io" - "io/ioutil" - "net/http" - "net/url" - "strings" -) - -type URLParameters map[string]string - -type HTTPOptionalParams struct { - Headers http.Header - URLParameters URLParameters - Body url.Values -} - -// Construct an URL including on parameters -func HTTPConstructURL(baseURL string, parameters URLParameters) (string, error) { - url, parseErr := url.Parse(baseURL) - if parseErr != nil { - return "", &HTTPConstructURLError{URL: baseURL, Parameters: parameters, Err: parseErr} - } - - q := url.Query() - - for parameter, value := range parameters { - q.Set(parameter, value) - } - url.RawQuery = q.Encode() - return url.String(), nil -} - -// Convenience functions -func HTTPGet(url string) (http.Header, []byte, error) { - return HTTPMethodWithOpts(http.MethodGet, url, nil) -} - -func HTTPPost(url string, body url.Values) (http.Header, []byte, error) { - return HTTPMethodWithOpts(http.MethodGet, url, &HTTPOptionalParams{Body: body}) -} - -func HTTPGetWithOpts(url string, opts *HTTPOptionalParams) (http.Header, []byte, error) { - return HTTPMethodWithOpts(http.MethodGet, url, opts) -} - -func HTTPPostWithOpts(url string, opts *HTTPOptionalParams) (http.Header, []byte, error) { - return HTTPMethodWithOpts(http.MethodPost, url, opts) -} - -func httpOptionalURL(url string, opts *HTTPOptionalParams) (string, error) { - if opts != nil { - url, urlErr := HTTPConstructURL(url, opts.URLParameters) - - if urlErr != nil { - return url, &HTTPRequestCreateError{URL: url, Err: urlErr} - } - return url, nil - } - return url, nil -} - -func httpOptionalHeaders(req *http.Request, opts *HTTPOptionalParams) { - // Add headers - if opts != nil && req != nil { - for k, v := range opts.Headers { - req.Header.Add(k, v[0]) - } - } -} - -func httpOptionalBodyReader(opts *HTTPOptionalParams) io.Reader { - if opts != nil && opts.Body != nil { - return strings.NewReader(opts.Body.Encode()) - } - return nil -} - -func HTTPMethodWithOpts(method string, url string, opts *HTTPOptionalParams) (http.Header, []byte, error) { - // Make sure the url contains all the parameters - // This can return an error, - // it already has the right error so so we don't wrap it further - url, urlErr := httpOptionalURL(url, opts) - if urlErr != nil { - // No further type wrapping is needed here - return nil, nil, urlErr - } - - // Create a client - client := &http.Client{} - - // Create request object with the body reader generated from the optional arguments - req, reqErr := http.NewRequest(method, url, httpOptionalBodyReader(opts)) - if reqErr != nil { - return nil, nil, &HTTPRequestCreateError{URL: url, Err: reqErr} - } - - // See https://stackoverflow.com/questions/17714494/golang-http-request-results-in-eof-errors-when-making-multiple-requests-successi - req.Close = true - - // Make sure the headers contain all the parameters - httpOptionalHeaders(req, opts) - - // Do request - resp, respErr := client.Do(req) - if respErr != nil { - return nil, nil, &HTTPResourceError{URL: url, Err: respErr} - } - - // Request successful, make sure body is closed at the end - defer resp.Body.Close() - - // Return a string - body, readErr := ioutil.ReadAll(resp.Body) - if readErr != nil { - return resp.Header, nil, &HTTPReadError{URL: url, Err: readErr} - } - - if resp.StatusCode < 200 || resp.StatusCode > 299 { - return resp.Header, body, &HTTPStatusError{URL: url, Status: resp.StatusCode} - } - - // Return the body in bytes and signal the status error if there was one - return resp.Header, body, nil -} - -type HTTPResourceError struct { - URL string - Err error -} - -func (e *HTTPResourceError) Error() string { - return fmt.Sprintf("failed obtaining HTTP resource: %s with error: %v", e.URL, e.Err) -} - -type HTTPStatusError struct { - URL string - Status int -} - -func (e *HTTPStatusError) Error() string { - return fmt.Sprintf("failed obtaining HTTP resource: %s as it gave an unsuccesful status code: %d", e.URL, e.Status) -} - -type HTTPReadError struct { - URL string - Err error -} - -func (e *HTTPReadError) Error() string { - return fmt.Sprintf("failed reading HTTP resource: %s with error: %v", e.URL, e.Err) -} - -type HTTPParseJsonError struct { - URL string - Body string - Err error -} - -func (e *HTTPParseJsonError) Error() string { - return fmt.Sprintf("failed parsing json %s for HTTP resource: %s with error: %v", e.Body, e.URL, e.Err) -} - -type HTTPRequestCreateError struct { - URL string - Err error -} - -func (e *HTTPRequestCreateError) Error() string { - return fmt.Sprintf("failed to create HTTP request with url: %s and error: %v", e.URL, e.Err) -} - -type HTTPConstructURLError struct { - URL string - Parameters URLParameters - Err error -} - -func (e *HTTPConstructURLError) Error() string { - return fmt.Sprintf("failed to construct url: %s including parameters: %v with error: %v", e.URL, e.Parameters, e.Err) -} diff --git a/internal/http/http.go b/internal/http/http.go new file mode 100644 index 0000000..87346f1 --- /dev/null +++ b/internal/http/http.go @@ -0,0 +1,183 @@ +package http + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "strings" +) + +type URLParameters map[string]string + +type HTTPOptionalParams struct { + Headers http.Header + URLParameters URLParameters + Body url.Values +} + +// Construct an URL including on parameters +func HTTPConstructURL(baseURL string, parameters URLParameters) (string, error) { + url, parseErr := url.Parse(baseURL) + if parseErr != nil { + return "", &HTTPConstructURLError{URL: baseURL, Parameters: parameters, Err: parseErr} + } + + q := url.Query() + + for parameter, value := range parameters { + q.Set(parameter, value) + } + url.RawQuery = q.Encode() + return url.String(), nil +} + +// Convenience functions +func HTTPGet(url string) (http.Header, []byte, error) { + return HTTPMethodWithOpts(http.MethodGet, url, nil) +} + +func HTTPPost(url string, body url.Values) (http.Header, []byte, error) { + return HTTPMethodWithOpts(http.MethodGet, url, &HTTPOptionalParams{Body: body}) +} + +func HTTPGetWithOpts(url string, opts *HTTPOptionalParams) (http.Header, []byte, error) { + return HTTPMethodWithOpts(http.MethodGet, url, opts) +} + +func HTTPPostWithOpts(url string, opts *HTTPOptionalParams) (http.Header, []byte, error) { + return HTTPMethodWithOpts(http.MethodPost, url, opts) +} + +func httpOptionalURL(url string, opts *HTTPOptionalParams) (string, error) { + if opts != nil { + url, urlErr := HTTPConstructURL(url, opts.URLParameters) + + if urlErr != nil { + return url, &HTTPRequestCreateError{URL: url, Err: urlErr} + } + return url, nil + } + return url, nil +} + +func httpOptionalHeaders(req *http.Request, opts *HTTPOptionalParams) { + // Add headers + if opts != nil && req != nil { + for k, v := range opts.Headers { + req.Header.Add(k, v[0]) + } + } +} + +func httpOptionalBodyReader(opts *HTTPOptionalParams) io.Reader { + if opts != nil && opts.Body != nil { + return strings.NewReader(opts.Body.Encode()) + } + return nil +} + +func HTTPMethodWithOpts(method string, url string, opts *HTTPOptionalParams) (http.Header, []byte, error) { + // Make sure the url contains all the parameters + // This can return an error, + // it already has the right error so so we don't wrap it further + url, urlErr := httpOptionalURL(url, opts) + if urlErr != nil { + // No further type wrapping is needed here + return nil, nil, urlErr + } + + // Create a client + client := &http.Client{} + + // Create request object with the body reader generated from the optional arguments + req, reqErr := http.NewRequest(method, url, httpOptionalBodyReader(opts)) + if reqErr != nil { + return nil, nil, &HTTPRequestCreateError{URL: url, Err: reqErr} + } + + // See https://stackoverflow.com/questions/17714494/golang-http-request-results-in-eof-errors-when-making-multiple-requests-successi + req.Close = true + + // Make sure the headers contain all the parameters + httpOptionalHeaders(req, opts) + + // Do request + resp, respErr := client.Do(req) + if respErr != nil { + return nil, nil, &HTTPResourceError{URL: url, Err: respErr} + } + + // Request successful, make sure body is closed at the end + defer resp.Body.Close() + + // Return a string + body, readErr := ioutil.ReadAll(resp.Body) + if readErr != nil { + return resp.Header, nil, &HTTPReadError{URL: url, Err: readErr} + } + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return resp.Header, body, &HTTPStatusError{URL: url, Status: resp.StatusCode} + } + + // Return the body in bytes and signal the status error if there was one + return resp.Header, body, nil +} + +type HTTPResourceError struct { + URL string + Err error +} + +func (e *HTTPResourceError) Error() string { + return fmt.Sprintf("failed obtaining HTTP resource: %s with error: %v", e.URL, e.Err) +} + +type HTTPStatusError struct { + URL string + Status int +} + +func (e *HTTPStatusError) Error() string { + return fmt.Sprintf("failed obtaining HTTP resource: %s as it gave an unsuccesful status code: %d", e.URL, e.Status) +} + +type HTTPReadError struct { + URL string + Err error +} + +func (e *HTTPReadError) Error() string { + return fmt.Sprintf("failed reading HTTP resource: %s with error: %v", e.URL, e.Err) +} + +type HTTPParseJsonError struct { + URL string + Body string + Err error +} + +func (e *HTTPParseJsonError) Error() string { + return fmt.Sprintf("failed parsing json %s for HTTP resource: %s with error: %v", e.Body, e.URL, e.Err) +} + +type HTTPRequestCreateError struct { + URL string + Err error +} + +func (e *HTTPRequestCreateError) Error() string { + return fmt.Sprintf("failed to create HTTP request with url: %s and error: %v", e.URL, e.Err) +} + +type HTTPConstructURLError struct { + URL string + Parameters URLParameters + Err error +} + +func (e *HTTPConstructURLError) Error() string { + return fmt.Sprintf("failed to construct url: %s including parameters: %v with error: %v", e.URL, e.Parameters, e.Err) +} diff --git a/internal/log.go b/internal/log.go deleted file mode 100644 index 5109ba2..0000000 --- a/internal/log.go +++ /dev/null @@ -1,77 +0,0 @@ -package internal - -import ( - "fmt" - "log" - "os" - "path" -) - -type FileLogger struct { - Level LogLevel - File *os.File -} - -type LogLevel int8 - -const ( - LOG_NOTSET LogLevel = iota - LOG_INFO - LOG_WARNING - LOG_ERROR -) - -func (e LogLevel) String() string { - switch e { - case LOG_NOTSET: - return "NOTSET" - case LOG_INFO: - return "INFO" - case LOG_WARNING: - return "WARNING" - case LOG_ERROR: - return "ERROR" - default: - return "UNKNOWN" - } -} - -func (logger *FileLogger) Init(level LogLevel, name string, directory string) error { - configDirErr := EnsureDirectory(directory) - if configDirErr != nil { - return &LogInitializeError{Name: name, Directory: directory, Err: configDirErr} - } - logFile, logOpenErr := os.OpenFile(logger.getFilename(directory, name), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666) - if logOpenErr != nil { - return &LogInitializeError{Name: name, Directory: directory, Err: logOpenErr} - } - log.SetOutput(logFile) - logger.File = logFile - logger.Level = level - return nil -} - -func (logger *FileLogger) getFilename(directory string, name string) string { - pathString := path.Join(directory, name) - return fmt.Sprintf("%s.log", pathString) -} - -func (logger *FileLogger) Log(level LogLevel, str string) { - if level >= logger.Level && logger.Level != LOG_NOTSET { - log.Printf("[%s]: %s", level.String(), str) - } -} - -func (logger *FileLogger) Close() { - logger.File.Close() -} - -type LogInitializeError struct { - Name string - Directory string - Err error -} - -func (e *LogInitializeError) Error() string { - return fmt.Sprintf("failed initializing logging with name: %s and directory: %s with error: %v", e.Name, e.Directory, e.Err) -} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..cba3364 --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,78 @@ +package log + +import ( + "fmt" + "log" + "os" + "path" + "github.com/jwijenbergh/eduvpn-common/internal/util" +) + +type FileLogger struct { + Level LogLevel + File *os.File +} + +type LogLevel int8 + +const ( + LOG_NOTSET LogLevel = iota + LOG_INFO + LOG_WARNING + LOG_ERROR +) + +func (e LogLevel) String() string { + switch e { + case LOG_NOTSET: + return "NOTSET" + case LOG_INFO: + return "INFO" + case LOG_WARNING: + return "WARNING" + case LOG_ERROR: + return "ERROR" + default: + return "UNKNOWN" + } +} + +func (logger *FileLogger) Init(level LogLevel, name string, directory string) error { + configDirErr := util.EnsureDirectory(directory) + if configDirErr != nil { + return &LogInitializeError{Name: name, Directory: directory, Err: configDirErr} + } + logFile, logOpenErr := os.OpenFile(logger.getFilename(directory, name), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666) + if logOpenErr != nil { + return &LogInitializeError{Name: name, Directory: directory, Err: logOpenErr} + } + log.SetOutput(logFile) + logger.File = logFile + logger.Level = level + return nil +} + +func (logger *FileLogger) getFilename(directory string, name string) string { + pathString := path.Join(directory, name) + return fmt.Sprintf("%s.log", pathString) +} + +func (logger *FileLogger) Log(level LogLevel, str string) { + if level >= logger.Level && logger.Level != LOG_NOTSET { + log.Printf("[%s]: %s", level.String(), str) + } +} + +func (logger *FileLogger) Close() { + logger.File.Close() +} + +type LogInitializeError struct { + Name string + Directory string + Err error +} + +func (e *LogInitializeError) Error() string { + return fmt.Sprintf("failed initializing logging with name: %s and directory: %s with error: %v", e.Name, e.Directory, e.Err) +} diff --git a/internal/oauth.go b/internal/oauth.go deleted file mode 100644 index c566425..0000000 --- a/internal/oauth.go +++ /dev/null @@ -1,430 +0,0 @@ -package internal - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/url" -) - -// Generates a random base64 string to be used for state -// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-4.1.1 -// "state": OPTIONAL. An opaque value used by the client to maintain -// state between the request and callback. The authorization server -// includes this value when redirecting the user agent back to the -// client. -func genState() (string, error) { - randomBytes, err := MakeRandomByteSlice(32) - if err != nil { - return "", &OAuthGenStateError{Err: err} - } - - // For consistency we also use raw url encoding here - return base64.RawURLEncoding.EncodeToString(randomBytes), nil -} - -// Generates a sha256 base64 challenge from a verifier -// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-7.8 -func genChallengeS256(verifier string) string { - hash := sha256.Sum256([]byte(verifier)) - - // We use raw url encoding as the challenge does not accept padding - return base64.RawURLEncoding.EncodeToString(hash[:]) -} - -// Generates a verifier -// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-4.1.1 -// The code_verifier is a unique high-entropy cryptographically random -// string generated for each authorization request, using the unreserved -// characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~", with a -// minimum length of 43 characters and a maximum length of 128 -// characters. -func genVerifier() (string, error) { - randomBytes, err := MakeRandomByteSlice(32) - if err != nil { - return "", &OAuthGenVerifierError{Err: err} - } - - return base64.RawURLEncoding.EncodeToString(randomBytes), nil -} - -type OAuth struct { - Session OAuthExchangeSession `json:"-"` - Token OAuthToken `json:"token"` - BaseAuthorizationURL string `json:"base_authorization_url"` - TokenURL string `json:"token_url"` - Logger *FileLogger `json:"-"` - FSM *FSM `json:"-"` -} - -// This structure gets passed to the callback for easy access to the current state -type OAuthExchangeSession struct { - // returned from the callback - CallbackError error - - // filled in in initialize - ClientID string - State string - Verifier string - - // filled in when constructing the callback - Context context.Context - Server http.Server -} - -// Struct that defines the json format for /.well-known/vpn-user-portal" -type OAuthToken struct { - Access string `json:"access_token"` - Refresh string `json:"refresh_token"` - Type string `json:"token_type"` - Expires int64 `json:"expires_in"` - ExpiredTimestamp int64 `json:"expires_in_timestamp"` -} - -// Gets an authorized HTTP client by obtaining refresh and access tokens -func (oauth *OAuth) getTokensWithCallback() error { - oauth.Session.Context = context.Background() - mux := http.NewServeMux() - addr := "127.0.0.1:8000" - oauth.Session.Server = http.Server{ - Addr: addr, - Handler: mux, - } - mux.HandleFunc("/callback", oauth.Callback) - if err := oauth.Session.Server.ListenAndServe(); err != http.ErrServerClosed { - return &OAuthCallbackError{Addr: addr, Err: err} - } - return oauth.Session.CallbackError -} - -// Get the access and refresh tokens -// Access tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.4 -// Refresh tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.3.2 -func (oauth *OAuth) getTokensWithAuthCode(authCode string) error { - // Make sure the verifier is set as the parameter - // so that the server can verify that we are the actual owner of the authorization code - - reqURL := oauth.TokenURL - data := url.Values{ - "client_id": {oauth.Session.ClientID}, - "code": {authCode}, - "code_verifier": {oauth.Session.Verifier}, - "grant_type": {"authorization_code"}, - "redirect_uri": {"http://127.0.0.1:8000/callback"}, - } - headers := http.Header{ - "content-type": {"application/x-www-form-urlencoded"}, - } - opts := &HTTPOptionalParams{Headers: headers, Body: data} - current_time := GenerateTimeSeconds() - _, body, bodyErr := HTTPPostWithOpts(reqURL, opts) - if bodyErr != nil { - return &OAuthAuthError{Err: bodyErr} - } - - tokenStructure := OAuthToken{} - - jsonErr := json.Unmarshal(body, &tokenStructure) - - if jsonErr != nil { - return &HTTPParseJsonError{URL: reqURL, Body: string(body), Err: jsonErr} - } - - tokenStructure.ExpiredTimestamp = current_time + tokenStructure.Expires - oauth.Token = tokenStructure - return nil -} - -func (oauth *OAuth) isTokensExpired() bool { - expired_time := oauth.Token.ExpiredTimestamp - current_time := GenerateTimeSeconds() - return current_time >= expired_time -} - -// Get the access and refresh tokens with a previously received refresh token -// Access tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.4 -// Refresh tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.3.2 -func (oauth *OAuth) getTokensWithRefresh() error { - reqURL := oauth.TokenURL - data := url.Values{ - "refresh_token": {oauth.Token.Refresh}, - "grant_type": {"refresh_token"}, - } - headers := http.Header{ - "content-type": {"application/x-www-form-urlencoded"}, - } - opts := &HTTPOptionalParams{Headers: headers, Body: data} - current_time := GenerateTimeSeconds() - _, body, bodyErr := HTTPPostWithOpts(reqURL, opts) - if bodyErr != nil { - return &OAuthRefreshError{Err: bodyErr} - } - - tokenStructure := OAuthToken{} - jsonErr := json.Unmarshal(body, &tokenStructure) - - if jsonErr != nil { - return &HTTPParseJsonError{URL: reqURL, Body: string(body), Err: jsonErr} - } - - tokenStructure.ExpiredTimestamp = current_time + tokenStructure.Expires - oauth.Token = tokenStructure - return nil -} - -// -//// The callback to retrieve the authorization code: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.3.1 -func (oauth *OAuth) Callback(w http.ResponseWriter, req *http.Request) { - // Extract the authorization code - code, success := req.URL.Query()["code"] - if !success { - oauth.Session.CallbackError = &OAuthCallbackParameterError{Parameter: "code", URL: req.URL.String()} - go oauth.Session.Server.Shutdown(oauth.Session.Context) - return - } - // The code is the first entry - extractedCode := code[0] - - // Make sure the state is present and matches to protect against cross-site request forgeries - // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-7.15 - state, success := req.URL.Query()["state"] - if !success { - oauth.Session.CallbackError = &OAuthCallbackParameterError{Parameter: "state", URL: req.URL.String()} - go oauth.Session.Server.Shutdown(oauth.Session.Context) - return - } - // The state is the first entry - extractedState := state[0] - if extractedState != oauth.Session.State { - oauth.Session.CallbackError = &OAuthCallbackStateMatchError{State: extractedState, ExpectedState: oauth.Session.State} - go oauth.Session.Server.Shutdown(oauth.Session.Context) - return - } - - // Now that we have obtained the authorization code, we can move to the next step: - // Obtaining the access and refresh tokens - err := oauth.getTokensWithAuthCode(extractedCode) - if err != nil { - oauth.Session.CallbackError = &OAuthCallbackGetTokensError{Err: err} - go oauth.Session.Server.Shutdown(oauth.Session.Context) - return - } - - // Shutdown the server as we're done listening - go oauth.Session.Server.Shutdown(oauth.Session.Context) -} - -func (oauth *OAuth) Update(fsm *FSM, logger *FileLogger) { - oauth.FSM = fsm - oauth.Logger = logger -} - -func (oauth *OAuth) Init(baseAuthorizationURL string, tokenURL string, fsm *FSM, logger *FileLogger) { - oauth.BaseAuthorizationURL = baseAuthorizationURL - oauth.TokenURL = tokenURL - oauth.FSM = fsm - oauth.Logger = logger -} - -// Starts the OAuth exchange for eduvpn. -func (oauth *OAuth) start(name string) error { - if !oauth.FSM.HasTransition(OAUTH_STARTED) { - return &FSMWrongStateTransitionError{Got: oauth.FSM.Current, Want: OAUTH_STARTED} - } - // Generate the state - state, stateErr := genState() - if stateErr != nil { - return &OAuthInitializeError{Err: stateErr} - } - - // Generate the verifier and challenge - verifier, verifierErr := genVerifier() - if verifierErr != nil { - return &OAuthInitializeError{Err: verifierErr} - } - challenge := genChallengeS256(verifier) - - parameters := map[string]string{ - "client_id": name, - "code_challenge_method": "S256", - "code_challenge": challenge, - "response_type": "code", - "scope": "config", - "state": state, - "redirect_uri": "http://127.0.0.1:8000/callback", - } - - authURL, urlErr := HTTPConstructURL(oauth.BaseAuthorizationURL, parameters) - - if urlErr != nil { - return &OAuthInitializeError{Err: urlErr} - } - - // Fill the struct with the necessary fields filled for the next call to getting the HTTP client - oauthSession := OAuthExchangeSession{ClientID: name, State: state, Verifier: verifier} - oauth.Session = oauthSession - // Run the state callback in the background so that the user can login while we start the callback server - oauth.FSM.GoTransitionWithData(OAUTH_STARTED, authURL, true) - return nil -} - -// Error definitions -func (oauth *OAuth) Finish() error { - if !oauth.FSM.HasTransition(AUTHORIZED) { - return &FSMWrongStateError{Got: oauth.FSM.Current, Want: AUTHORIZED} - } - tokenErr := oauth.getTokensWithCallback() - - if tokenErr != nil { - return &OAuthFinishError{Err: tokenErr} - } - oauth.FSM.GoTransition(AUTHORIZED) - return nil -} - -func (oauth *OAuth) Cancel() { - oauth.Session.CallbackError = &OAuthCancelledCallbackError{} - oauth.Session.Server.Shutdown(oauth.Session.Context) -} - -func (oauth *OAuth) Login(name string) error { - authInitializeErr := oauth.start(name) - - if authInitializeErr != nil { - return &OAuthLoginError{Err: authInitializeErr} - } - - oauthErr := oauth.Finish() - - if oauthErr != nil { - return &OAuthLoginError{Err: oauthErr} - } - return nil -} - -func (oauth *OAuth) NeedsRelogin() bool { - // Access Token or Refresh Tokens empty, definitely needs a relogin - if oauth.Token.Access == "" || oauth.Token.Refresh == "" { - oauth.Logger.Log(LOG_INFO, "OAuth: Tokens are empty") - return true - } - - // We have tokens... - - // The tokens are not expired yet - // No relogin is needed - if !oauth.isTokensExpired() { - oauth.Logger.Log(LOG_INFO, "OAuth: Tokens are not expired, re-login not needed") - return false - } - - refreshErr := oauth.getTokensWithRefresh() - // We have obtained new tokens with refresh - if refreshErr == nil { - oauth.Logger.Log(LOG_INFO, "OAuth: Tokens could be re-acquired using the refresh token, re-login not needed") - return false - } - - // Otherwise relogin is really needed - return true -} - -type OAuthCancelledCallbackError struct{} - -func (e *OAuthCancelledCallbackError) Error() string { - return fmt.Sprintf("client cancelled OAuth") -} - -type OAuthGenStateError struct { - Err error -} - -func (e *OAuthGenStateError) Error() string { - return fmt.Sprintf("failed generating state with error: %v", e.Err) -} - -type OAuthGenVerifierError struct { - Err error -} - -func (e *OAuthGenVerifierError) Error() string { - return fmt.Sprintf("failed generating verifier with error: %v", e.Err) -} - -type OAuthCallbackError struct { - Addr string - Err error -} - -func (e *OAuthCallbackError) Error() string { - return fmt.Sprintf("failed callback: %s with error: %v", e.Addr, e.Err) -} - -type OAuthCallbackParameterError struct { - Parameter string - URL string -} - -func (e *OAuthCallbackParameterError) Error() string { - return fmt.Sprintf("failed retrieving parameter: %s in url: %s", e.Parameter, e.URL) -} - -type OAuthCallbackStateMatchError struct { - State string - ExpectedState string -} - -func (e *OAuthCallbackStateMatchError) Error() string { - return fmt.Sprintf("failed matching state, got: %s, want: %s", e.State, e.ExpectedState) -} - -type OAuthCallbackGetTokensError struct { - Err error -} - -func (e *OAuthCallbackGetTokensError) Error() string { - return fmt.Sprintf("failed getting tokens with error: %v", e.Err) -} - -type OAuthFinishError struct { - Err error -} - -func (e *OAuthFinishError) Error() string { - return fmt.Sprintf("failed finishing OAuth with error: %v", e.Err) -} - -type OAuthLoginError struct { - Err error -} - -func (e *OAuthLoginError) Error() string { - return fmt.Sprintf("failed OAuth logging in with error: %v", e.Err) -} - -type OAuthInitializeError struct { - Err error -} - -func (e *OAuthInitializeError) Error() string { - return fmt.Sprintf("failed initializing OAuth with error: %v", e.Err) -} - -type OAuthAuthError struct { - Err error -} - -func (e *OAuthAuthError) Error() string { - return fmt.Sprintf("failed getting tokens with auth code for OAuth with error: %v", e.Err) -} - -type OAuthRefreshError struct { - Err error -} - -func (e *OAuthRefreshError) Error() string { - return fmt.Sprintf("failed refreshing tokens for OAuth with error: %v", e.Err) -} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go new file mode 100644 index 0000000..f6ed916 --- /dev/null +++ b/internal/oauth/oauth.go @@ -0,0 +1,434 @@ +package oauth + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "github.com/jwijenbergh/eduvpn-common/internal/fsm" + httpw "github.com/jwijenbergh/eduvpn-common/internal/http" + "github.com/jwijenbergh/eduvpn-common/internal/util" + "github.com/jwijenbergh/eduvpn-common/internal/log" +) + +// Generates a random base64 string to be used for state +// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-4.1.1 +// "state": OPTIONAL. An opaque value used by the client to maintain +// state between the request and callback. The authorization server +// includes this value when redirecting the user agent back to the +// client. +func genState() (string, error) { + randomBytes, err := util.MakeRandomByteSlice(32) + if err != nil { + return "", &OAuthGenStateError{Err: err} + } + + // For consistency we also use raw url encoding here + return base64.RawURLEncoding.EncodeToString(randomBytes), nil +} + +// Generates a sha256 base64 challenge from a verifier +// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-7.8 +func genChallengeS256(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + + // We use raw url encoding as the challenge does not accept padding + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +// Generates a verifier +// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-4.1.1 +// The code_verifier is a unique high-entropy cryptographically random +// string generated for each authorization request, using the unreserved +// characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~", with a +// minimum length of 43 characters and a maximum length of 128 +// characters. +func genVerifier() (string, error) { + randomBytes, err := util.MakeRandomByteSlice(32) + if err != nil { + return "", &OAuthGenVerifierError{Err: err} + } + + return base64.RawURLEncoding.EncodeToString(randomBytes), nil +} + +type OAuth struct { + Session OAuthExchangeSession `json:"-"` + Token OAuthToken `json:"token"` + BaseAuthorizationURL string `json:"base_authorization_url"` + TokenURL string `json:"token_url"` + Logger *log.FileLogger `json:"-"` + FSM *fsm.FSM `json:"-"` +} + +// This structure gets passed to the callback for easy access to the current state +type OAuthExchangeSession struct { + // returned from the callback + CallbackError error + + // filled in in initialize + ClientID string + State string + Verifier string + + // filled in when constructing the callback + Context context.Context + Server http.Server +} + +// Struct that defines the json format for /.well-known/vpn-user-portal" +type OAuthToken struct { + Access string `json:"access_token"` + Refresh string `json:"refresh_token"` + Type string `json:"token_type"` + Expires int64 `json:"expires_in"` + ExpiredTimestamp int64 `json:"expires_in_timestamp"` +} + +// Gets an authorized HTTP client by obtaining refresh and access tokens +func (oauth *OAuth) getTokensWithCallback() error { + oauth.Session.Context = context.Background() + mux := http.NewServeMux() + addr := "127.0.0.1:8000" + oauth.Session.Server = http.Server{ + Addr: addr, + Handler: mux, + } + mux.HandleFunc("/callback", oauth.Callback) + if err := oauth.Session.Server.ListenAndServe(); err != http.ErrServerClosed { + return &OAuthCallbackError{Addr: addr, Err: err} + } + return oauth.Session.CallbackError +} + +// Get the access and refresh tokens +// Access tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.4 +// Refresh tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.3.2 +func (oauth *OAuth) getTokensWithAuthCode(authCode string) error { + // Make sure the verifier is set as the parameter + // so that the server can verify that we are the actual owner of the authorization code + + reqURL := oauth.TokenURL + data := url.Values{ + "client_id": {oauth.Session.ClientID}, + "code": {authCode}, + "code_verifier": {oauth.Session.Verifier}, + "grant_type": {"authorization_code"}, + "redirect_uri": {"http://127.0.0.1:8000/callback"}, + } + headers := http.Header{ + "content-type": {"application/x-www-form-urlencoded"}, + } + opts := &httpw.HTTPOptionalParams{Headers: headers, Body: data} + current_time := util.GenerateTimeSeconds() + _, body, bodyErr := httpw.HTTPPostWithOpts(reqURL, opts) + if bodyErr != nil { + return &OAuthAuthError{Err: bodyErr} + } + + tokenStructure := OAuthToken{} + + jsonErr := json.Unmarshal(body, &tokenStructure) + + if jsonErr != nil { + return &httpw.HTTPParseJsonError{URL: reqURL, Body: string(body), Err: jsonErr} + } + + tokenStructure.ExpiredTimestamp = current_time + tokenStructure.Expires + oauth.Token = tokenStructure + return nil +} + +func (oauth *OAuth) isTokensExpired() bool { + expired_time := oauth.Token.ExpiredTimestamp + current_time := util.GenerateTimeSeconds() + return current_time >= expired_time +} + +// Get the access and refresh tokens with a previously received refresh token +// Access tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.4 +// Refresh tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.3.2 +func (oauth *OAuth) getTokensWithRefresh() error { + reqURL := oauth.TokenURL + data := url.Values{ + "refresh_token": {oauth.Token.Refresh}, + "grant_type": {"refresh_token"}, + } + headers := http.Header{ + "content-type": {"application/x-www-form-urlencoded"}, + } + opts := &httpw.HTTPOptionalParams{Headers: headers, Body: data} + current_time := util.GenerateTimeSeconds() + _, body, bodyErr := httpw.HTTPPostWithOpts(reqURL, opts) + if bodyErr != nil { + return &OAuthRefreshError{Err: bodyErr} + } + + tokenStructure := OAuthToken{} + jsonErr := json.Unmarshal(body, &tokenStructure) + + if jsonErr != nil { + return &httpw.HTTPParseJsonError{URL: reqURL, Body: string(body), Err: jsonErr} + } + + tokenStructure.ExpiredTimestamp = current_time + tokenStructure.Expires + oauth.Token = tokenStructure + return nil +} + +// +//// The callback to retrieve the authorization code: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.3.1 +func (oauth *OAuth) Callback(w http.ResponseWriter, req *http.Request) { + // Extract the authorization code + code, success := req.URL.Query()["code"] + if !success { + oauth.Session.CallbackError = &OAuthCallbackParameterError{Parameter: "code", URL: req.URL.String()} + go oauth.Session.Server.Shutdown(oauth.Session.Context) + return + } + // The code is the first entry + extractedCode := code[0] + + // Make sure the state is present and matches to protect against cross-site request forgeries + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-7.15 + state, success := req.URL.Query()["state"] + if !success { + oauth.Session.CallbackError = &OAuthCallbackParameterError{Parameter: "state", URL: req.URL.String()} + go oauth.Session.Server.Shutdown(oauth.Session.Context) + return + } + // The state is the first entry + extractedState := state[0] + if extractedState != oauth.Session.State { + oauth.Session.CallbackError = &OAuthCallbackStateMatchError{State: extractedState, ExpectedState: oauth.Session.State} + go oauth.Session.Server.Shutdown(oauth.Session.Context) + return + } + + // Now that we have obtained the authorization code, we can move to the next step: + // Obtaining the access and refresh tokens + err := oauth.getTokensWithAuthCode(extractedCode) + if err != nil { + oauth.Session.CallbackError = &OAuthCallbackGetTokensError{Err: err} + go oauth.Session.Server.Shutdown(oauth.Session.Context) + return + } + + // Shutdown the server as we're done listening + go oauth.Session.Server.Shutdown(oauth.Session.Context) +} + +func (oauth *OAuth) Update(fsm *fsm.FSM, logger *log.FileLogger) { + oauth.FSM = fsm + oauth.Logger = logger +} + +func (oauth *OAuth) Init(baseAuthorizationURL string, tokenURL string, fsm *fsm.FSM, logger *log.FileLogger) { + oauth.BaseAuthorizationURL = baseAuthorizationURL + oauth.TokenURL = tokenURL + oauth.FSM = fsm + oauth.Logger = logger +} + +// Starts the OAuth exchange for eduvpn. +func (oauth *OAuth) start(name string) error { + if !oauth.FSM.HasTransition(fsm.OAUTH_STARTED) { + return &fsm.FSMWrongStateTransitionError{Got: oauth.FSM.Current, Want: fsm.OAUTH_STARTED} + } + // Generate the state + state, stateErr := genState() + if stateErr != nil { + return &OAuthInitializeError{Err: stateErr} + } + + // Generate the verifier and challenge + verifier, verifierErr := genVerifier() + if verifierErr != nil { + return &OAuthInitializeError{Err: verifierErr} + } + challenge := genChallengeS256(verifier) + + parameters := map[string]string{ + "client_id": name, + "code_challenge_method": "S256", + "code_challenge": challenge, + "response_type": "code", + "scope": "config", + "state": state, + "redirect_uri": "http://127.0.0.1:8000/callback", + } + + authURL, urlErr := httpw.HTTPConstructURL(oauth.BaseAuthorizationURL, parameters) + + if urlErr != nil { + return &OAuthInitializeError{Err: urlErr} + } + + // Fill the struct with the necessary fields filled for the next call to getting the HTTP client + oauthSession := OAuthExchangeSession{ClientID: name, State: state, Verifier: verifier} + oauth.Session = oauthSession + // Run the state callback in the background so that the user can login while we start the callback server + oauth.FSM.GoTransitionWithData(fsm.OAUTH_STARTED, authURL, true) + return nil +} + +// Error definitions +func (oauth *OAuth) Finish() error { + if !oauth.FSM.HasTransition(fsm.AUTHORIZED) { + return &fsm.FSMWrongStateError{Got: oauth.FSM.Current, Want: fsm.AUTHORIZED} + } + tokenErr := oauth.getTokensWithCallback() + + if tokenErr != nil { + return &OAuthFinishError{Err: tokenErr} + } + oauth.FSM.GoTransition(fsm.AUTHORIZED) + return nil +} + +func (oauth *OAuth) Cancel() { + oauth.Session.CallbackError = &OAuthCancelledCallbackError{} + oauth.Session.Server.Shutdown(oauth.Session.Context) +} + +func (oauth *OAuth) Login(name string) error { + authInitializeErr := oauth.start(name) + + if authInitializeErr != nil { + return &OAuthLoginError{Err: authInitializeErr} + } + + oauthErr := oauth.Finish() + + if oauthErr != nil { + return &OAuthLoginError{Err: oauthErr} + } + return nil +} + +func (oauth *OAuth) NeedsRelogin() bool { + // Access Token or Refresh Tokens empty, definitely needs a relogin + if oauth.Token.Access == "" || oauth.Token.Refresh == "" { + oauth.Logger.Log(log.LOG_INFO, "OAuth: Tokens are empty") + return true + } + + // We have tokens... + + // The tokens are not expired yet + // No relogin is needed + if !oauth.isTokensExpired() { + oauth.Logger.Log(log.LOG_INFO, "OAuth: Tokens are not expired, re-login not needed") + return false + } + + refreshErr := oauth.getTokensWithRefresh() + // We have obtained new tokens with refresh + if refreshErr == nil { + oauth.Logger.Log(log.LOG_INFO, "OAuth: Tokens could be re-acquired using the refresh token, re-login not needed") + return false + } + + // Otherwise relogin is really needed + return true +} + +type OAuthCancelledCallbackError struct{} + +func (e *OAuthCancelledCallbackError) Error() string { + return fmt.Sprintf("client cancelled OAuth") +} + +type OAuthGenStateError struct { + Err error +} + +func (e *OAuthGenStateError) Error() string { + return fmt.Sprintf("failed generating state with error: %v", e.Err) +} + +type OAuthGenVerifierError struct { + Err error +} + +func (e *OAuthGenVerifierError) Error() string { + return fmt.Sprintf("failed generating verifier with error: %v", e.Err) +} + +type OAuthCallbackError struct { + Addr string + Err error +} + +func (e *OAuthCallbackError) Error() string { + return fmt.Sprintf("failed callback: %s with error: %v", e.Addr, e.Err) +} + +type OAuthCallbackParameterError struct { + Parameter string + URL string +} + +func (e *OAuthCallbackParameterError) Error() string { + return fmt.Sprintf("failed retrieving parameter: %s in url: %s", e.Parameter, e.URL) +} + +type OAuthCallbackStateMatchError struct { + State string + ExpectedState string +} + +func (e *OAuthCallbackStateMatchError) Error() string { + return fmt.Sprintf("failed matching state, got: %s, want: %s", e.State, e.ExpectedState) +} + +type OAuthCallbackGetTokensError struct { + Err error +} + +func (e *OAuthCallbackGetTokensError) Error() string { + return fmt.Sprintf("failed getting tokens with error: %v", e.Err) +} + +type OAuthFinishError struct { + Err error +} + +func (e *OAuthFinishError) Error() string { + return fmt.Sprintf("failed finishing OAuth with error: %v", e.Err) +} + +type OAuthLoginError struct { + Err error +} + +func (e *OAuthLoginError) Error() string { + return fmt.Sprintf("failed OAuth logging in with error: %v", e.Err) +} + +type OAuthInitializeError struct { + Err error +} + +func (e *OAuthInitializeError) Error() string { + return fmt.Sprintf("failed initializing OAuth with error: %v", e.Err) +} + +type OAuthAuthError struct { + Err error +} + +func (e *OAuthAuthError) Error() string { + return fmt.Sprintf("failed getting tokens with auth code for OAuth with error: %v", e.Err) +} + +type OAuthRefreshError struct { + Err error +} + +func (e *OAuthRefreshError) Error() string { + return fmt.Sprintf("failed refreshing tokens for OAuth with error: %v", e.Err) +} diff --git a/internal/openvpn.go b/internal/openvpn.go deleted file mode 100644 index 8f684ba..0000000 --- a/internal/openvpn.go +++ /dev/null @@ -1,31 +0,0 @@ -package internal - -import "fmt" - -func OpenVPNGetConfig(server Server) (string, string, error) { - base, baseErr := server.GetBase() - - if baseErr != nil { - return "", "", &OpenVPNGetConfigError{Err: baseErr} - } - profile_id := base.Profiles.Current - configOpenVPN, expires, configErr := APIConnectOpenVPN(server, profile_id) - - // Store start and end time - base.StartTime = GenerateTimeSeconds() - base.EndTime = expires - - if configErr != nil { - return "", "", &OpenVPNGetConfigError{Err: configErr} - } - - return configOpenVPN, "openvpn", nil -} - -type OpenVPNGetConfigError struct { - Err error -} - -func (e *OpenVPNGetConfigError) Error() string { - return fmt.Sprintf("failed getting OpenVPN config with error: %v", e.Err) -} diff --git a/internal/server.go b/internal/server.go deleted file mode 100644 index d1fc433..0000000 --- a/internal/server.go +++ /dev/null @@ -1,540 +0,0 @@ -package internal - -import ( - "encoding/json" - "fmt" -) - -// The base type for servers -type ServerBase struct { - URL string `json:"base_url"` - Endpoints ServerEndpoints `json:"endpoints"` - Profiles ServerProfileInfo `json:"profiles"` - ProfilesRaw string `json:"profiles_raw"` - Logger *FileLogger `json:"-"` - FSM *FSM `json:"-"` - StartTime int64 `json:"start-time"` - EndTime int64 `json:"end-time"` -} - -// An instute access server -type InstituteAccessServer struct { - // An instute access server has its own OAuth - OAuth OAuth `json:"oauth"` - - // Embed the server base - Base ServerBase `json:"base"` -} - -// A secure internet server which has its own OAuth tokens -// It specifies the current location url it is connected to -type SecureInternetHomeServer struct { - OAuth OAuth `json:"oauth"` - - // The home server has a list of info for each configured server - BaseMap map[string]*ServerBase `json:"base_map"` - - // We have the home url and the current url - HomeURL string `json:"home_url"` - CurrentURL string `json:"current_url"` -} - -type InstituteServers struct { - Map map[string]*InstituteAccessServer `json:"map"` - CurrentURL string `json:"current_url"` -} - -func (servers *Servers) GetCurrentServer() (Server, error) { - if servers.IsSecureInternet { - return &servers.SecureInternetHomeServer, nil - } - currentInstitute := servers.InstituteServers.CurrentURL - institutes := servers.InstituteServers.Map - if institutes == nil { - return nil, &ServerGetCurrentNoMapError{} - } - institute, exists := institutes[currentInstitute] - - if !exists || institute == nil { - return nil, &ServerGetCurrentNotFoundError{} - } - return institute, nil -} - -type Servers struct { - InstituteServers InstituteServers `json:"institute_servers"` - SecureInternetHomeServer SecureInternetHomeServer `json:"secure_internet_home"` - IsSecureInternet bool `json:"is_secure_internet"` -} - -type Server interface { - // Gets the current OAuth object - GetOAuth() *OAuth - - // Gets the server base - GetBase() (*ServerBase, error) - - // initialize method - init(url string, fsm *FSM, logger *FileLogger) error -} - -// For an institute, we can simply get the OAuth -func (institute *InstituteAccessServer) GetOAuth() *OAuth { - return &institute.OAuth -} - -func (secure *SecureInternetHomeServer) GetOAuth() *OAuth { - return &secure.OAuth -} - -func (institute *InstituteAccessServer) GetBase() (*ServerBase, error) { - return &institute.Base, nil -} - -func (server *SecureInternetHomeServer) GetBase() (*ServerBase, error) { - if server.BaseMap == nil { - return nil, &ServerSecureInternetMapNotFoundError{} - } - - base, exists := server.BaseMap[server.CurrentURL] - - if !exists { - return nil, &ServerSecureInternetBaseNotFoundError{Current: server.CurrentURL} - } - return base, nil -} - -func (institute *InstituteAccessServer) init(url string, fsm *FSM, logger *FileLogger) error { - institute.Base.URL = url - institute.Base.FSM = fsm - institute.Base.Logger = logger - endpoints, endpointsErr := getEndpoints(url) - if endpointsErr != nil { - return &ServerInitializeError{URL: url, Err: endpointsErr} - } - institute.OAuth.Init(endpoints.API.V3.Authorization, endpoints.API.V3.Token, fsm, logger) - institute.Base.Endpoints = *endpoints - return nil -} - -func (secure *SecureInternetHomeServer) init(url string, fsm *FSM, logger *FileLogger) error { - // Initialize the base map if it is non-nil - if secure.BaseMap == nil { - secure.BaseMap = make(map[string]*ServerBase) - } - - // Add it if not present - base, exists := secure.BaseMap[url] - - if !exists || base == nil { - // Create the base to be added to the map - base = &ServerBase{} - base.URL = url - endpoints, endpointsErr := getEndpoints(url) - if endpointsErr != nil { - return &ServerInitializeError{URL: url, Err: endpointsErr} - } - base.Endpoints = *endpoints - } - - // Pass the fsm and logger - base.FSM = fsm - base.Logger = logger - - // Ensure it is in the map - secure.BaseMap[url] = base - - // Set the home url if it is not set yet - if secure.HomeURL == "" { - secure.HomeURL = url - // Make sure oauth contains our endpoints - secure.OAuth.Init(base.Endpoints.API.V3.Authorization, base.Endpoints.API.V3.Token, fsm, logger) - } else { // Else just pass in the fsm and logger - secure.OAuth.Update(fsm, logger) - } - - // Set the current url - secure.CurrentURL = url - return nil -} - -func Login(server Server) error { - return server.GetOAuth().Login("org.eduvpn.app.linux") -} - -func EnsureTokens(server Server) error { - base, baseErr := server.GetBase() - - if baseErr != nil { - return &ServerEnsureTokensError{Err: baseErr} - } - if server.GetOAuth().NeedsRelogin() { - base.Logger.Log(LOG_INFO, "OAuth: Tokens are invalid, relogging in") - loginErr := Login(server) - - if loginErr != nil { - return &ServerEnsureTokensError{Err: loginErr} - } - } - return nil -} - -func NeedsRelogin(server Server) bool { - return server.GetOAuth().NeedsRelogin() -} - -func CancelOAuth(server Server) { - server.GetOAuth().Cancel() -} - -func (servers *Servers) EnsureServer(url string, isSecureInternet bool, fsm *FSM, logger *FileLogger) (Server, error) { - // Intialize the secure internet server - // This calls the init method which takes care of the rest - if isSecureInternet { - initErr := servers.SecureInternetHomeServer.init(url, fsm, logger) - - if initErr != nil { - return nil, &ServerEnsureServerError{Err: initErr} - } - - servers.IsSecureInternet = true - return &servers.SecureInternetHomeServer, nil - } - - instituteServers := &servers.InstituteServers - - if instituteServers.Map == nil { - instituteServers.Map = make(map[string]*InstituteAccessServer) - } - - institute, exists := instituteServers.Map[url] - - // initialize the server if it doesn't exist yet - if !exists { - institute = &InstituteAccessServer{} - } - - // Set the current server - instituteServers.CurrentURL = url - instituteInitErr := institute.init(url, fsm, logger) - if instituteInitErr != nil { - return nil, &ServerEnsureServerError{Err: instituteInitErr} - } - instituteServers.Map[url] = institute - servers.IsSecureInternet = false - return institute, nil -} - -type ServerProfile struct { - ID string `json:"profile_id"` - DisplayName string `json:"display_name"` - VPNProtoList []string `json:"vpn_proto_list"` - DefaultGateway bool `json:"default_gateway"` -} - -type ServerProfileInfo struct { - Current string `json:"current_profile"` - Info struct { - ProfileList []ServerProfile `json:"profile_list"` - } `json:"info"` -} - -type ServerEndpointList struct { - API string `json:"api_endpoint"` - Authorization string `json:"authorization_endpoint"` - Token string `json:"token_endpoint"` -} - -// Struct that defines the json format for /.well-known/vpn-user-portal" -type ServerEndpoints struct { - API struct { - V2 ServerEndpointList `json:"http://eduvpn.org/api#2"` - V3 ServerEndpointList `json:"http://eduvpn.org/api#3"` - } `json:"api"` - V string `json:"v"` -} - -// Make this a var which we can overwrite in the tests -var WellKnownPath string = ".well-known/vpn-user-portal" - -func getEndpoints(baseURL string) (*ServerEndpoints, error) { - url := fmt.Sprintf("%s/%s", baseURL, WellKnownPath) - _, body, bodyErr := HTTPGet(url) - - if bodyErr != nil { - return nil, &ServerGetEndpointsError{Err: bodyErr} - } - - endpoints := &ServerEndpoints{} - jsonErr := json.Unmarshal(body, endpoints) - - if jsonErr != nil { - return nil, &ServerGetEndpointsError{Err: jsonErr} - } - - return endpoints, nil -} - -func (profile *ServerProfile) supportsProtocol(protocol string) bool { - for _, proto := range profile.VPNProtoList { - if proto == protocol { - return true - } - } - return false -} - -func (profile *ServerProfile) supportsWireguard() bool { - return profile.supportsProtocol("wireguard") -} - -func (profile *ServerProfile) supportsOpenVPN() bool { - return profile.supportsProtocol("openvpn") -} - -func getCurrentProfile(server Server) (*ServerProfile, error) { - base, baseErr := server.GetBase() - - if baseErr != nil { - return nil, &ServerGetCurrentProfileError{Err: baseErr} - } - profileID := base.Profiles.Current - for _, profile := range base.Profiles.Info.ProfileList { - if profile.ID == profileID { - return &profile, nil - } - } - return nil, &ServerGetCurrentProfileNotFoundError{ProfileID: profileID} -} - -func getConfigWithProfile(server Server, forceTCP bool) (string, string, error) { - base, baseErr := server.GetBase() - - if baseErr != nil { - return "", "", &ServerGetConfigWithProfileError{Err: baseErr} - } - if !base.FSM.HasTransition(HAS_CONFIG) { - return "", "", &FSMWrongStateTransitionError{Got: base.FSM.Current, Want: HAS_CONFIG} - } - profile, profileErr := getCurrentProfile(server) - - if profileErr != nil { - return "", "", &ServerGetConfigWithProfileError{Err: profileErr} - } - - supportsOpenVPN := profile.supportsOpenVPN() - supportsWireguard := profile.supportsWireguard() - - // If forceTCP we must be able to get a config with OpenVPN - if forceTCP && supportsOpenVPN { - return "", "", &ServerGetConfigForceTCPError{} - } - - var config string - var configType string - var configErr error - - if supportsWireguard { - // A wireguard connect call needs to generate a wireguard key and add it to the config - // Also the server could send back an OpenVPN config if it supports OpenVPN - config, configType, configErr = WireguardGetConfig(server, supportsOpenVPN) - } else { - config, configType, configErr = OpenVPNGetConfig(server) - } - - if configErr != nil { - return "", "", &ServerGetConfigWithProfileError{Err: configErr} - } - - return config, configType, nil -} - -func askForProfileID(server Server) error { - base, baseErr := server.GetBase() - - if baseErr != nil { - return &ServerAskForProfileIDError{Err: baseErr} - } - if !base.FSM.HasTransition(ASK_PROFILE) { - return &FSMWrongStateTransitionError{Got: base.FSM.Current, Want: ASK_PROFILE} - } - base.FSM.GoTransitionWithData(ASK_PROFILE, base.ProfilesRaw, false) - return nil -} - -func GetConfig(server Server, forceTCP bool) (string, string, error) { - base, baseErr := server.GetBase() - - if baseErr != nil { - return "", "", &ServerGetConfigError{Err: baseErr} - } - if !base.FSM.InState(REQUEST_CONFIG) { - return "", "", &FSMWrongStateError{Got: base.FSM.Current, Want: REQUEST_CONFIG} - } - - // Get new profiles using the info call - // This does not override the current profile - infoErr := APIInfo(server) - if infoErr != nil { - return "", "", &ServerGetConfigError{Err: infoErr} - } - - // If there was a profile chosen and it doesn't exist anymore, reset it - if base.Profiles.Current != "" { - _, existsProfileErr := getCurrentProfile(server) - if existsProfileErr != nil { - base.Logger.Log(LOG_INFO, fmt.Sprintf("Profile %s no longer exists, resetting the profile", base.Profiles.Current)) - base.Profiles.Current = "" - } - } - - // Set the current profile if there is only one profile or profile is already selected - if len(base.Profiles.Info.ProfileList) == 1 || base.Profiles.Current != "" { - // Set the first profile if none is selected - if base.Profiles.Current == "" { - base.Profiles.Current = base.Profiles.Info.ProfileList[0].ID - } - return getConfigWithProfile(server, forceTCP) - } - - profileErr := askForProfileID(server) - - if profileErr != nil { - return "", "", &ServerGetConfigError{Err: profileErr} - } - - return getConfigWithProfile(server, forceTCP) -} - -type ServerGetCurrentProfileNotFoundError struct { - ProfileID string -} - -func (e *ServerGetCurrentProfileNotFoundError) Error() string { - return fmt.Sprintf("failed to get current profile, profile with ID: %s not found", e.ProfileID) -} - -type ServerGetConfigWithProfileError struct { - Err error -} - -func (e *ServerGetConfigWithProfileError) Error() string { - return fmt.Sprintf("failed to get config including profile with error %v", e.Err) -} - -type ServerGetConfigForceTCPError struct{} - -func (e *ServerGetConfigForceTCPError) Error() string { - return fmt.Sprintf("failed to get config, force TCP is on but the server does not support OpenVPN") -} - -type ServerGetEndpointsError struct { - Err error -} - -func (e *ServerGetEndpointsError) Error() string { - return fmt.Sprintf("failed to get server endpoint with error %v", e.Err) -} - -type ServerGetSecureInternetHomeError struct{} - -func (e *ServerGetSecureInternetHomeError) Error() string { - return "failed to get secure internet home server, not found" -} - -type ServerCopySecureInternetOAuthError struct { - Err error -} - -func (e *ServerCopySecureInternetOAuthError) Error() string { - return fmt.Sprintf("failed to copy oauth tokens from home server with error %v", e.Err) -} - -type ServerEnsureServerEmptyURLError struct{} - -func (e *ServerEnsureServerEmptyURLError) Error() string { - return "failed ensuring server, empty url provided" -} - -type ServerEnsureServerError struct { - Err error -} - -func (e *ServerEnsureServerError) Error() string { - return fmt.Sprintf("failed ensuring server with error %v", e.Err) -} - -type ServerGetCurrentNoMapError struct{} - -func (e *ServerGetCurrentNoMapError) Error() string { - return "failed getting current server, no servers available" -} - -type ServerGetCurrentNotFoundError struct{} - -func (e *ServerGetCurrentNotFoundError) Error() string { - return "failed getting current server, not found" -} - -type ServerGetConfigError struct { - Err error -} - -func (e *ServerGetConfigError) Error() string { - return fmt.Sprintf("failed getting server config with error %v", e.Err) -} - -type ServerInitializeError struct { - URL string - Err error -} - -func (e *ServerInitializeError) Error() string { - return fmt.Sprintf("failed initializing server with url %s and error %v", e.URL, e.Err) -} - -type ServerInstituteBaseNotFoundError struct { - Err error -} - -func (e *ServerInstituteBaseNotFoundError) Error() string { - return "institute base not found" -} - -type ServerSecureInternetMapNotFoundError struct{} - -func (e *ServerSecureInternetMapNotFoundError) Error() string { - return "secure internet map not found" -} - -type ServerSecureInternetBaseNotFoundError struct { - Current string -} - -func (e *ServerSecureInternetBaseNotFoundError) Error() string { - return fmt.Sprintf("secure internet base not found with current: %s", e.Current) -} - -type ServerGetCurrentProfileError struct { - Err error -} - -func (e *ServerGetCurrentProfileError) Error() string { - return fmt.Sprintf("failed getting current profile with error: %v", e.Err) -} - -type ServerAskForProfileIDError struct { - Err error -} - -func (e *ServerAskForProfileIDError) Error() string { - return fmt.Sprintf("ask for profile ID error: %v", e.Err) -} - -type ServerEnsureTokensError struct { - Err error -} - -func (e *ServerEnsureTokensError) Error() string { - return fmt.Sprintf("failed ensuring tokens with error: %v", e.Err) -} diff --git a/internal/server/api.go b/internal/server/api.go new file mode 100644 index 0000000..96bd641 --- /dev/null +++ b/internal/server/api.go @@ -0,0 +1,221 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + httpw "github.com/jwijenbergh/eduvpn-common/internal/http" + "github.com/jwijenbergh/eduvpn-common/internal/log" + "github.com/jwijenbergh/eduvpn-common/internal/util" +) + +func APIGetEndpoints(baseURL string) (*ServerEndpoints, error) { + url := fmt.Sprintf("%s/%s", baseURL, WellKnownPath) + _, body, bodyErr := httpw.HTTPGet(url) + + if bodyErr != nil { + return nil, &APIGetEndpointsError{Err: bodyErr} + } + + endpoints := &ServerEndpoints{} + jsonErr := json.Unmarshal(body, endpoints) + + if jsonErr != nil { + return nil, &APIGetEndpointsError{Err: jsonErr} + } + + return endpoints, nil +} + +// Authorized wrappers on top of HTTP +// the errors will not be wrapped here so that the caller can check if we got a status error, to retry oauth +func apiAuthorized(server Server, method string, endpoint string, opts *httpw.HTTPOptionalParams) (http.Header, []byte, error) { + // Ensure optional is not nil as we will fill it with headers + if opts == nil { + opts = &httpw.HTTPOptionalParams{} + } + base, baseErr := server.GetBase() + + if baseErr != nil { + return nil, nil, baseErr + } + + url := base.Endpoints.API.V3.API + endpoint + + // Ensure we have valid tokens + stateBefore := base.FSM.Current + oauthErr := EnsureTokens(server) + + // we reset the state so that we go from the authorized state to the state we want + base.FSM.Current = stateBefore + + if oauthErr != nil { + return nil, nil, oauthErr + } + + headerKey := "Authorization" + headerValue := fmt.Sprintf("Bearer %s", server.GetOAuth().Token.Access) + if opts.Headers != nil { + opts.Headers.Add(headerKey, headerValue) + } else { + opts.Headers = http.Header{headerKey: {headerValue}} + } + return httpw.HTTPMethodWithOpts(method, url, opts) +} + +func apiAuthorizedRetry(server Server, method string, endpoint string, opts *httpw.HTTPOptionalParams) (http.Header, []byte, error) { + header, body, bodyErr := apiAuthorized(server, method, endpoint, opts) + base, baseErr := server.GetBase() + + if baseErr != nil { + return nil, nil, &APIAuthorizedError{Err: baseErr} + } + if bodyErr != nil { + var error *httpw.HTTPStatusError + + // Only retry authorized if we get a HTTP 401 + if errors.As(bodyErr, &error) && error.Status == 401 { + base.Logger.Log(log.LOG_INFO, fmt.Sprintf("API: Got HTTP error %v, retrying authorized", error)) + // Tell the method that the token is expired + server.GetOAuth().Token.ExpiredTimestamp = util.GenerateTimeSeconds() + retryHeader, retryBody, retryErr := apiAuthorized(server, method, endpoint, opts) + if retryErr != nil { + return nil, nil, &APIAuthorizedError{Err: retryErr} + } + return retryHeader, retryBody, nil + } + return nil, nil, &APIAuthorizedError{Err: bodyErr} + } + return header, body, nil +} + +func APIInfo(server Server) error { + _, body, bodyErr := apiAuthorizedRetry(server, http.MethodGet, "/info", nil) + if bodyErr != nil { + return &APIInfoError{Err: bodyErr} + } + structure := ServerProfileInfo{} + jsonErr := json.Unmarshal(body, &structure) + + if jsonErr != nil { + return &APIInfoError{Err: jsonErr} + } + + base, baseErr := server.GetBase() + + if baseErr != nil { + return &APIInfoError{Err: baseErr} + } + + // Store the profiles and make sure that the current profile is not overwritten + previousProfile := base.Profiles.Current + base.Profiles = structure + base.Profiles.Current = previousProfile + base.ProfilesRaw = string(body) + return nil +} + +func APIConnectWireguard(server Server, profile_id string, pubkey string, supportsOpenVPN bool) (string, string, int64, error) { + headers := http.Header{ + "content-type": {"application/x-www-form-urlencoded"}, + "accept": {"application/x-wireguard-profile"}, + } + + if supportsOpenVPN { + headers.Add("accept", "application/x-openvpn-profile") + } + + urlForm := url.Values{ + "profile_id": {profile_id}, + "public_key": {pubkey}, + } + header, connectBody, connectErr := apiAuthorizedRetry(server, http.MethodPost, "/connect", &httpw.HTTPOptionalParams{Headers: headers, Body: urlForm}) + if connectErr != nil { + return "", "", 0, &APIConnectWireguardError{Err: connectErr} + } + + expires := header.Get("expires") + + pTime, pTimeErr := http.ParseTime(expires) + if pTimeErr != nil { + return "", "", 0, &APIConnectWireguardError{Err: pTimeErr} + } + + contentType := header.Get("content-type") + + content := "openvpn" + if contentType == "application/x-wireguard-profile" { + content = "wireguard" + } + return string(connectBody), content, pTime.Unix(), nil +} + +func APIConnectOpenVPN(server Server, profile_id string) (string, int64, error) { + headers := http.Header{ + "content-type": {"application/x-www-form-urlencoded"}, + "accept": {"application/x-openvpn-profile"}, + } + + urlForm := url.Values{ + "profile_id": {profile_id}, + } + + header, connectBody, connectErr := apiAuthorizedRetry(server, http.MethodPost, "/connect", &httpw.HTTPOptionalParams{Headers: headers, Body: urlForm}) + if connectErr != nil { + return "", 0, &APIConnectOpenVPNError{Err: connectErr} + } + + expires := header.Get("expires") + pTime, pTimeErr := http.ParseTime(expires) + if pTimeErr != nil { + return "", 0, &APIConnectOpenVPNError{Err: pTimeErr} + } + return string(connectBody), pTime.Unix(), nil +} + +// This needs no further return value as it's best effort +func APIDisconnect(server Server) { + apiAuthorizedRetry(server, http.MethodPost, "/disconnect", nil) +} + +type APIAuthorizedError struct { + Err error +} + +func (e *APIAuthorizedError) Error() string { + return fmt.Sprintf("failed api authorized call with error: %v", e.Err) +} + +type APIConnectWireguardError struct { + Err error +} + +func (e *APIConnectWireguardError) Error() string { + return fmt.Sprintf("failed api /connect wireguard call with error: %v", e.Err) +} + +type APIConnectOpenVPNError struct { + Err error +} + +func (e *APIConnectOpenVPNError) Error() string { + return fmt.Sprintf("failed api /connect OpenVPN call with error: %v", e.Err) +} + +type APIInfoError struct { + Err error +} + +func (e *APIInfoError) Error() string { + return fmt.Sprintf("failed api /info call with error: %v", e.Err) +} + +type APIGetEndpointsError struct { + Err error +} + +func (e *APIGetEndpointsError) Error() string { + return fmt.Sprintf("failed to get server endpoint with error %v", e.Err) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..a1fb749 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,605 @@ +package server + +import ( + "fmt" + "github.com/jwijenbergh/eduvpn-common/internal/fsm" + "github.com/jwijenbergh/eduvpn-common/internal/log" + "github.com/jwijenbergh/eduvpn-common/internal/oauth" + "github.com/jwijenbergh/eduvpn-common/internal/util" + "github.com/jwijenbergh/eduvpn-common/internal/wireguard" +) + +// The base type for servers +type ServerBase struct { + URL string `json:"base_url"` + Endpoints ServerEndpoints `json:"endpoints"` + Profiles ServerProfileInfo `json:"profiles"` + ProfilesRaw string `json:"profiles_raw"` + StartTime int64 `json:"start-time"` + EndTime int64 `json:"end-time"` + Logger *log.FileLogger `json:"-"` + FSM *fsm.FSM `json:"-"` +} + +// An instute access server +type InstituteAccessServer struct { + // An instute access server has its own OAuth + OAuth oauth.OAuth `json:"oauth"` + + // Embed the server base + Base ServerBase `json:"base"` +} + +// A secure internet server which has its own OAuth tokens +// It specifies the current location url it is connected to +type SecureInternetHomeServer struct { + OAuth oauth.OAuth `json:"oauth"` + + // The home server has a list of info for each configured server + BaseMap map[string]*ServerBase `json:"base_map"` + + // We have the home url and the current url + HomeURL string `json:"home_url"` + CurrentURL string `json:"current_url"` +} + +type InstituteServers struct { + Map map[string]*InstituteAccessServer `json:"map"` + CurrentURL string `json:"current_url"` +} + +func (servers *Servers) GetCurrentServer() (Server, error) { + if servers.IsSecureInternet { + return &servers.SecureInternetHomeServer, nil + } + currentInstitute := servers.InstituteServers.CurrentURL + institutes := servers.InstituteServers.Map + if institutes == nil { + return nil, &ServerGetCurrentNoMapError{} + } + institute, exists := institutes[currentInstitute] + + if !exists || institute == nil { + return nil, &ServerGetCurrentNotFoundError{} + } + return institute, nil +} + +type Servers struct { + InstituteServers InstituteServers `json:"institute_servers"` + SecureInternetHomeServer SecureInternetHomeServer `json:"secure_internet_home"` + IsSecureInternet bool `json:"is_secure_internet"` +} + +type Server interface { + // Gets the current OAuth object + GetOAuth() *oauth.OAuth + + // Gets the server base + GetBase() (*ServerBase, error) + + // initialize method + init(url string, fsm *fsm.FSM, logger *log.FileLogger) error +} + +// For an institute, we can simply get the OAuth +func (institute *InstituteAccessServer) GetOAuth() *oauth.OAuth { + return &institute.OAuth +} + +func (secure *SecureInternetHomeServer) GetOAuth() *oauth.OAuth { + return &secure.OAuth +} + +func (institute *InstituteAccessServer) GetBase() (*ServerBase, error) { + return &institute.Base, nil +} + +func (server *SecureInternetHomeServer) GetBase() (*ServerBase, error) { + if server.BaseMap == nil { + return nil, &ServerSecureInternetMapNotFoundError{} + } + + base, exists := server.BaseMap[server.CurrentURL] + + if !exists { + return nil, &ServerSecureInternetBaseNotFoundError{Current: server.CurrentURL} + } + return base, nil +} + +func (institute *InstituteAccessServer) init(url string, fsm *fsm.FSM, logger *log.FileLogger) error { + institute.Base.URL = url + institute.Base.FSM = fsm + institute.Base.Logger = logger + endpoints, endpointsErr := APIGetEndpoints(url) + if endpointsErr != nil { + return &ServerInitializeError{URL: url, Err: endpointsErr} + } + institute.OAuth.Init(endpoints.API.V3.Authorization, endpoints.API.V3.Token, fsm, logger) + institute.Base.Endpoints = *endpoints + return nil +} + +func (secure *SecureInternetHomeServer) init(url string, fsm *fsm.FSM, logger *log.FileLogger) error { + // Initialize the base map if it is non-nil + if secure.BaseMap == nil { + secure.BaseMap = make(map[string]*ServerBase) + } + + // Add it if not present + base, exists := secure.BaseMap[url] + + if !exists || base == nil { + // Create the base to be added to the map + base = &ServerBase{} + base.URL = url + endpoints, endpointsErr := APIGetEndpoints(url) + if endpointsErr != nil { + return &ServerInitializeError{URL: url, Err: endpointsErr} + } + base.Endpoints = *endpoints + } + + // Pass the fsm and logger + base.FSM = fsm + base.Logger = logger + + // Ensure it is in the map + secure.BaseMap[url] = base + + // Set the home url if it is not set yet + if secure.HomeURL == "" { + secure.HomeURL = url + // Make sure oauth contains our endpoints + secure.OAuth.Init(base.Endpoints.API.V3.Authorization, base.Endpoints.API.V3.Token, fsm, logger) + } else { // Else just pass in the fsm and logger + secure.OAuth.Update(fsm, logger) + } + + // Set the current url + secure.CurrentURL = url + return nil +} + +func ShouldRenewButton(server Server) (bool, error) { + base, baseErr := server.GetBase() + + if baseErr != nil { + //return false, &GetRenewButtonTimeError{Err: baseErr} + return false, nil + } + + // Get current time + current := util.GenerateTimeSeconds() + + // 30 minutes have not passed + if current <= (base.StartTime + 30*60) { + return false, nil + } + + // Session will not expire today + if current <= (base.EndTime - 24*60*60) { + return false, nil + } + + // Session duration is less than 24 hours but not 75% has passed + duration := base.EndTime - base.StartTime + // TODO: Is converting to float64 okay here? + if duration < 24*60*60 && float64(current) <= (float64(base.StartTime) + 0.75*float64(duration)) { + return false, nil + } + + return true, nil +} + +func Login(server Server) error { + return server.GetOAuth().Login("org.eduvpn.app.linux") +} + +func EnsureTokens(server Server) error { + base, baseErr := server.GetBase() + + if baseErr != nil { + return &ServerEnsureTokensError{Err: baseErr} + } + if server.GetOAuth().NeedsRelogin() { + base.Logger.Log(log.LOG_INFO, "OAuth: Tokens are invalid, relogging in") + loginErr := Login(server) + + if loginErr != nil { + return &ServerEnsureTokensError{Err: loginErr} + } + } + return nil +} + +func NeedsRelogin(server Server) bool { + return server.GetOAuth().NeedsRelogin() +} + +func CancelOAuth(server Server) { + server.GetOAuth().Cancel() +} + +func (servers *Servers) EnsureServer(url string, isSecureInternet bool, fsm *fsm.FSM, logger *log.FileLogger) (Server, error) { + // Intialize the secure internet server + // This calls the init method which takes care of the rest + if isSecureInternet { + initErr := servers.SecureInternetHomeServer.init(url, fsm, logger) + + if initErr != nil { + return nil, &ServerEnsureServerError{Err: initErr} + } + + servers.IsSecureInternet = true + return &servers.SecureInternetHomeServer, nil + } + + instituteServers := &servers.InstituteServers + + if instituteServers.Map == nil { + instituteServers.Map = make(map[string]*InstituteAccessServer) + } + + institute, exists := instituteServers.Map[url] + + // initialize the server if it doesn't exist yet + if !exists { + institute = &InstituteAccessServer{} + } + + // Set the current server + instituteServers.CurrentURL = url + instituteInitErr := institute.init(url, fsm, logger) + if instituteInitErr != nil { + return nil, &ServerEnsureServerError{Err: instituteInitErr} + } + instituteServers.Map[url] = institute + servers.IsSecureInternet = false + return institute, nil +} + +type ServerProfile struct { + ID string `json:"profile_id"` + DisplayName string `json:"display_name"` + VPNProtoList []string `json:"vpn_proto_list"` + DefaultGateway bool `json:"default_gateway"` +} + +type ServerProfileInfo struct { + Current string `json:"current_profile"` + Info struct { + ProfileList []ServerProfile `json:"profile_list"` + } `json:"info"` +} + +type ServerEndpointList struct { + API string `json:"api_endpoint"` + Authorization string `json:"authorization_endpoint"` + Token string `json:"token_endpoint"` +} + +// Struct that defines the json format for /.well-known/vpn-user-portal" +type ServerEndpoints struct { + API struct { + V2 ServerEndpointList `json:"http://eduvpn.org/api#2"` + V3 ServerEndpointList `json:"http://eduvpn.org/api#3"` + } `json:"api"` + V string `json:"v"` +} + +// Make this a var which we can overwrite in the tests +var WellKnownPath string = ".well-known/vpn-user-portal" + +func (profile *ServerProfile) supportsProtocol(protocol string) bool { + for _, proto := range profile.VPNProtoList { + if proto == protocol { + return true + } + } + return false +} + +func (profile *ServerProfile) supportsWireguard() bool { + return profile.supportsProtocol("wireguard") +} + +func (profile *ServerProfile) supportsOpenVPN() bool { + return profile.supportsProtocol("openvpn") +} + +func getCurrentProfile(server Server) (*ServerProfile, error) { + base, baseErr := server.GetBase() + + if baseErr != nil { + return nil, &ServerGetCurrentProfileError{Err: baseErr} + } + profileID := base.Profiles.Current + for _, profile := range base.Profiles.Info.ProfileList { + if profile.ID == profileID { + return &profile, nil + } + } + return nil, &ServerGetCurrentProfileNotFoundError{ProfileID: profileID} +} + +func wireguardGetConfig(server Server, supportsOpenVPN bool) (string, string, error) { + base, baseErr := server.GetBase() + + if baseErr != nil { + return "", "", baseErr + } + + profile_id := base.Profiles.Current + wireguardKey, wireguardErr := wireguard.GenerateKey() + + if wireguardErr != nil { + return "", "", wireguardErr + } + + wireguardPublicKey := wireguardKey.PublicKey().String() + config, content, expires, configErr := APIConnectWireguard(server, profile_id, wireguardPublicKey, supportsOpenVPN) + + if configErr != nil { + return "", "", wireguardErr + } + + // Store start and end time + base.StartTime = util.GenerateTimeSeconds() + base.EndTime = expires + + if content == "wireguard" { + // This needs the go code a way to identify a connection + // Use the uuid of the connection e.g. on Linux + // This needs the client code to call the go code + + config = wireguard.ConfigAddKey(config, wireguardKey) + } + + return config, content, nil +} + +func openVPNGetConfig(server Server) (string, string, error) { + base, baseErr := server.GetBase() + + if baseErr != nil { + return "", "", baseErr + } + profile_id := base.Profiles.Current + configOpenVPN, expires, configErr := APIConnectOpenVPN(server, profile_id) + + // Store start and end time + base.StartTime = util.GenerateTimeSeconds() + base.EndTime = expires + + if configErr != nil { + return "", "", configErr + } + + return configOpenVPN, "openvpn", nil +} + +func getConfigWithProfile(server Server, forceTCP bool) (string, string, error) { + base, baseErr := server.GetBase() + + if baseErr != nil { + return "", "", &ServerGetConfigWithProfileError{Err: baseErr} + } + if !base.FSM.HasTransition(fsm.HAS_CONFIG) { + return "", "", &fsm.FSMWrongStateTransitionError{Got: base.FSM.Current, Want: fsm.HAS_CONFIG} + } + profile, profileErr := getCurrentProfile(server) + + if profileErr != nil { + return "", "", &ServerGetConfigWithProfileError{Err: profileErr} + } + + supportsOpenVPN := profile.supportsOpenVPN() + supportsWireguard := profile.supportsWireguard() + + // If forceTCP we must be able to get a config with OpenVPN + if forceTCP && supportsOpenVPN { + return "", "", &ServerGetConfigForceTCPError{} + } + + var config string + var configType string + var configErr error + + if supportsWireguard { + // A wireguard connect call needs to generate a wireguard key and add it to the config + // Also the server could send back an OpenVPN config if it supports OpenVPN + config, configType, configErr = wireguardGetConfig(server, supportsOpenVPN) + } else { + config, configType, configErr = openVPNGetConfig(server) + } + + if configErr != nil { + return "", "", &ServerGetConfigWithProfileError{Err: configErr} + } + + return config, configType, nil +} + +func askForProfileID(server Server) error { + base, baseErr := server.GetBase() + + if baseErr != nil { + return &ServerAskForProfileIDError{Err: baseErr} + } + if !base.FSM.HasTransition(fsm.ASK_PROFILE) { + return &fsm.FSMWrongStateTransitionError{Got: base.FSM.Current, Want: fsm.ASK_PROFILE} + } + base.FSM.GoTransitionWithData(fsm.ASK_PROFILE, base.ProfilesRaw, false) + return nil +} + +func GetConfig(server Server, forceTCP bool) (string, string, error) { + base, baseErr := server.GetBase() + + if baseErr != nil { + return "", "", &ServerGetConfigError{Err: baseErr} + } + if !base.FSM.InState(fsm.REQUEST_CONFIG) { + return "", "", &fsm.FSMWrongStateError{Got: base.FSM.Current, Want: fsm.REQUEST_CONFIG} + } + + // Get new profiles using the info call + // This does not override the current profile + infoErr := APIInfo(server) + if infoErr != nil { + return "", "", &ServerGetConfigError{Err: infoErr} + } + + // If there was a profile chosen and it doesn't exist anymore, reset it + if base.Profiles.Current != "" { + _, existsProfileErr := getCurrentProfile(server) + if existsProfileErr != nil { + base.Logger.Log(log.LOG_INFO, fmt.Sprintf("Profile %s no longer exists, resetting the profile", base.Profiles.Current)) + base.Profiles.Current = "" + } + } + + // Set the current profile if there is only one profile or profile is already selected + if len(base.Profiles.Info.ProfileList) == 1 || base.Profiles.Current != "" { + // Set the first profile if none is selected + if base.Profiles.Current == "" { + base.Profiles.Current = base.Profiles.Info.ProfileList[0].ID + } + return getConfigWithProfile(server, forceTCP) + } + + profileErr := askForProfileID(server) + + if profileErr != nil { + return "", "", &ServerGetConfigError{Err: profileErr} + } + + return getConfigWithProfile(server, forceTCP) +} + +type ServerGetCurrentProfileNotFoundError struct { + ProfileID string +} + +func (e *ServerGetCurrentProfileNotFoundError) Error() string { + return fmt.Sprintf("failed to get current profile, profile with ID: %s not found", e.ProfileID) +} + +type ServerGetConfigWithProfileError struct { + Err error +} + +func (e *ServerGetConfigWithProfileError) Error() string { + return fmt.Sprintf("failed to get config including profile with error %v", e.Err) +} + +type ServerGetConfigForceTCPError struct{} + +func (e *ServerGetConfigForceTCPError) Error() string { + return fmt.Sprintf("failed to get config, force TCP is on but the server does not support OpenVPN") +} + +type ServerGetSecureInternetHomeError struct{} + +func (e *ServerGetSecureInternetHomeError) Error() string { + return "failed to get secure internet home server, not found" +} + +type ServerCopySecureInternetOAuthError struct { + Err error +} + +func (e *ServerCopySecureInternetOAuthError) Error() string { + return fmt.Sprintf("failed to copy oauth tokens from home server with error %v", e.Err) +} + +type ServerEnsureServerEmptyURLError struct{} + +func (e *ServerEnsureServerEmptyURLError) Error() string { + return "failed ensuring server, empty url provided" +} + +type ServerEnsureServerError struct { + Err error +} + +func (e *ServerEnsureServerError) Error() string { + return fmt.Sprintf("failed ensuring server with error %v", e.Err) +} + +type ServerGetCurrentNoMapError struct{} + +func (e *ServerGetCurrentNoMapError) Error() string { + return "failed getting current server, no servers available" +} + +type ServerGetCurrentNotFoundError struct{} + +func (e *ServerGetCurrentNotFoundError) Error() string { + return "failed getting current server, not found" +} + +type ServerGetConfigError struct { + Err error +} + +func (e *ServerGetConfigError) Error() string { + return fmt.Sprintf("failed getting server config with error %v", e.Err) +} + +type ServerInitializeError struct { + URL string + Err error +} + +func (e *ServerInitializeError) Error() string { + return fmt.Sprintf("failed initializing server with url %s and error %v", e.URL, e.Err) +} + +type ServerInstituteBaseNotFoundError struct { + Err error +} + +func (e *ServerInstituteBaseNotFoundError) Error() string { + return "institute base not found" +} + +type ServerSecureInternetMapNotFoundError struct{} + +func (e *ServerSecureInternetMapNotFoundError) Error() string { + return "secure internet map not found" +} + +type ServerSecureInternetBaseNotFoundError struct { + Current string +} + +func (e *ServerSecureInternetBaseNotFoundError) Error() string { + return fmt.Sprintf("secure internet base not found with current: %s", e.Current) +} + +type ServerGetCurrentProfileError struct { + Err error +} + +func (e *ServerGetCurrentProfileError) Error() string { + return fmt.Sprintf("failed getting current profile with error: %v", e.Err) +} + +type ServerAskForProfileIDError struct { + Err error +} + +func (e *ServerAskForProfileIDError) Error() string { + return fmt.Sprintf("ask for profile ID error: %v", e.Err) +} + +type ServerEnsureTokensError struct { + Err error +} + +func (e *ServerEnsureTokensError) Error() string { + return fmt.Sprintf("failed ensuring tokens with error: %v", e.Err) +} diff --git a/internal/test_data/empty b/internal/test_data/empty deleted file mode 100644 index e69de29..0000000 diff --git a/internal/test_data/generate.sh b/internal/test_data/generate.sh deleted file mode 100644 index b1b4545..0000000 --- a/internal/test_data/generate.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash -# Generate testcases with fake keys - -# Make sure we do not delete *.minisigs etc. in the wrong directory -if [ ${PWD##*/} != "test_data" ] -then - >&2 echo "Wrong directory, should be run in test_data/" - exit 1 -fi - -rm -f *.minisig *.blake2b - -# Uncomment to regenerate keys -#rm -f *.key -#echo -en "\n\n" | minisign -Gf -p public.key -s secret.key & -#echo -en "\n\n" | minisign -Gf -p wrong_public.key -s wrong_secret.key & -#wait - -# Try to create pure signature with default Minisign (works with version < 0.10) -echo | minisign -Sm server_list.json -x server_list.json.pure.minisig -t $'timestamp:10\tfile:server_list.json' -s secret.key -# Check if it is actually a prehashed signature -if echo | minisign -VHm server_list.json -x server_list.json.pure.minisig -p public.key -then - echo "minisign version is >0.9, trying minisign-0.9" - # If it is, try to sign with some minisign-0.9 program - if ! echo | minisign-0.9 -Sm server_list.json -x server_list.json.pure.minisig -t $'timestamp:10\tfile:server_list.json' -s secret.key - then - >&2 echo -e "\n\nTo produce a non-prehashed signature we need Minisign 0.9\n\n" - fi -fi - -# Rest works with Minisign 0.9 and 0.10 (and up, probably) - -echo | minisign -SHm server_list.json -t $'timestamp:10\tfile:server_list.json\thashed' -s secret.key & -echo | minisign -SHm server_list.json -x server_list.json.tc_nohashed.minisig -t $'timestamp:10\tfile:server_list.json' -s secret.key & -echo | minisign -SHm server_list.json -x server_list.json.tc_latertime.minisig -t $'timestamp:20\tfile:server_list.json\t hashed' -s secret.key & -echo | minisign -SHm server_list.json -x server_list.json.tc_orglist.minisig -t $'timestamp:10\tfile:organization_list.json\thashed' -s secret.key & -wait -echo | minisign -SHm server_list.json -x server_list.json.tc_otherfile.minisig -t $'timestamp:10\tfile:otherfile\thashed' -s secret.key & -echo | minisign -SHm server_list.json -x server_list.json.tc_nofile.minisig -t $'timestamp:10\thashed' -s secret.key & -echo | minisign -SHm server_list.json -x server_list.json.tc_notime.minisig -t $'file:server_list.json\thashed' -s secret.key & -echo | minisign -SHm server_list.json -x server_list.json.tc_emptytime.minisig -t $'timestamp:\tfile:server_list.json\thashed' -s secret.key & -wait -echo | minisign -SHm server_list.json -x server_list.json.tc_emptyfile.minisig -t $'timestamp:10\tfile:\thashed' -s secret.key & -echo | minisign -SHm server_list.json -x server_list.json.tc_earliertime.minisig -t $'timestamp:9\tfile:server_list.json\thashed' -s secret.key & -echo | minisign -SHm server_list.json -x server_list.json.tc_random.minisig -t 'random stuff' -s secret.key & -echo | minisign -SHm server_list.json -x server_list.json.large_time.minisig -t $'timestamp:4300000000\tfile:server_list.json' -s secret.key & -wait - -echo | minisign -SHm organization_list.json -t $'timestamp:10\tfile:organization_list.json\thashed' -s secret.key & -echo | minisign -SHm organization_list.json -x organization_list.json.tc_servlist.minisig -t $'timestamp:10\tfile:server_list.json\thashed' -s secret.key & - -echo | minisign -SHm other_list.json -t $'timestamp:10\tfile:other_list.json\thashed' -s secret.key & - -echo | minisign -SHm server_list.json -x server_list.json.wrong_key.minisig -t $'timestamp:10\tfile:server_list.json\thashed' -s wrong_secret.key & -wait - -./generate_forged.py diff --git a/internal/test_data/generate_forged.py b/internal/test_data/generate_forged.py deleted file mode 100644 index 9d42adc..0000000 --- a/internal/test_data/generate_forged.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 - -import hashlib -import base64 - -# Hash server_list.json - -with open("server_list.json", "rb") as f: - b = f.read() - -with open("server_list.json.blake2b", "wb") as f: - f.write(hashlib.blake2b(b).digest()) - -# Forge pure signature on hash, see https://github.com/jedisct1/minisign/issues/104 - -with open("server_list.json.minisig", "rb") as f: - siglines = f.readlines() - -siglines[0] = b"untrusted comment: this signature has ED changed to Ed\n" -sig = base64.b64decode(siglines[1]) -siglines[1] = base64.b64encode(b"Ed" + sig[2:]) + b"\n" - -with open("server_list.json.forged_pure.minisig", "wb") as f: - f.writelines(siglines) - # Should now work: minisign -Vm server_list.json.blake2b -x server_list.json.forged_pure.minisig -p public-key - -# Try to forge key ID - -with open("server_list.json.wrong_key.minisig", "rb") as f: - siglines = f.readlines() - -siglines[ - 0 -] = b"untrusted comment: this signature was created with wrong_secret.key but has key ID changed to that of public.key\n" -sig_wrong = base64.b64decode(siglines[1]) -siglines[1] = ( - base64.b64encode(sig_wrong[:2] + sig[2 : 2 + 8] + sig_wrong[2 + 8 :]) + b"\n" -) - -with open("server_list.json.forged_keyid.minisig", "wb") as f: - f.writelines(siglines) diff --git a/internal/test_data/organization_list.json b/internal/test_data/organization_list.json deleted file mode 100644 index 8c53044..0000000 --- a/internal/test_data/organization_list.json +++ /dev/null @@ -1 +0,0 @@ -{"organization_list": [{}]} \ No newline at end of file diff --git a/internal/test_data/organization_list.json.minisig b/internal/test_data/organization_list.json.minisig deleted file mode 100644 index 1fa546e..0000000 --- a/internal/test_data/organization_list.json.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH31cHjNvTEh+TCqDVCwUgFVZoRdgWYAaQDxH3L3UIsRi9Qb1O4vLI4V1CYPatKzXZnSodSJM/AZgl9v7l/5bfPQ0= -trusted comment: timestamp:10 file:organization_list.json hashed -21zZv1DviMpLCdv1NgzLBl6d+F1ZllSNyjAquYxhTHGcs2F64bDFpqY0I0xjCHIoXly6HKqJKIBXNgud12ijCQ== diff --git a/internal/test_data/organization_list.json.tc_servlist.minisig b/internal/test_data/organization_list.json.tc_servlist.minisig deleted file mode 100644 index a7fe41f..0000000 --- a/internal/test_data/organization_list.json.tc_servlist.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH31cHjNvTEh+TCqDVCwUgFVZoRdgWYAaQDxH3L3UIsRi9Qb1O4vLI4V1CYPatKzXZnSodSJM/AZgl9v7l/5bfPQ0= -trusted comment: timestamp:10 file:server_list.json hashed -R6hjM/oMS5LAvpYM4F6E7iUpnlPxqiY0QfuOnpum31CW0sUy/Ypy2PiomSwvZXKVR7keEZS/+lZjyra9TkrLDQ== diff --git a/internal/test_data/other_list.json b/internal/test_data/other_list.json deleted file mode 100644 index 25ba1a8..0000000 --- a/internal/test_data/other_list.json +++ /dev/null @@ -1 +0,0 @@ -{"other_list": [{}]} \ No newline at end of file diff --git a/internal/test_data/other_list.json.minisig b/internal/test_data/other_list.json.minisig deleted file mode 100644 index eaa2248..0000000 --- a/internal/test_data/other_list.json.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH366C1RnYeUAgEeX/S5A1Z9qmkV2+GJaVj06FWGd4aMLc+HS7iFMhG69u3TVD4YmzMH12rk7hQrnyCC6ex8ypIQA= -trusted comment: timestamp:10 file:other_list.json hashed -26+608n+bjQF9lwNdXbIK6t5bP8dzhjNQ9hACeYJLiB2tr437Aec2GkmJh0jSiRv1QV4RYBcKJeHQBUcV2grCQ== diff --git a/internal/test_data/public.key b/internal/test_data/public.key deleted file mode 100644 index 72676d3..0000000 --- a/internal/test_data/public.key +++ /dev/null @@ -1,2 +0,0 @@ -untrusted comment: minisign public key DF07F868DFAB9B4C -RWRMm6vfaPgH39iT++NBiUKZim2nDWnalgkNROovPbZdSwVFgUdKU4ac diff --git a/internal/test_data/random.txt b/internal/test_data/random.txt deleted file mode 100644 index b6fc4c6..0000000 --- a/internal/test_data/random.txt +++ /dev/null @@ -1 +0,0 @@ -hello \ No newline at end of file diff --git a/internal/test_data/secret.key b/internal/test_data/secret.key deleted file mode 100644 index 6e4af37..0000000 --- a/internal/test_data/secret.key +++ /dev/null @@ -1,2 +0,0 @@ -untrusted comment: minisign encrypted secret key -RWRTY0IyobkTOt4ugAHNTPB6zOxHgX8spW6HQWddB5IrdCPDAgsAAAACAAAAAAAAAEAAAAAAvK1S1gsOgozZHuIdLWXq1IwxnWVr+dlySiykTbO6F85HvzPtgxZ7oLcGkT/vPdskAh0SV9H2ylHlt9oarXcWNDKs2r6EcZw/qy5FsD+5uhPfxwWV4qDF+1G456tYDYID63d50CgzdO0= diff --git a/internal/test_data/server_list.json b/internal/test_data/server_list.json deleted file mode 100644 index 67c4c8d..0000000 --- a/internal/test_data/server_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ -"server_list": [{}] -} \ No newline at end of file diff --git a/internal/test_data/server_list.json.blake2b b/internal/test_data/server_list.json.blake2b deleted file mode 100644 index 5d2ca5a..0000000 Binary files a/internal/test_data/server_list.json.blake2b and /dev/null differ diff --git a/internal/test_data/server_list.json.forged_keyid.minisig b/internal/test_data/server_list.json.forged_keyid.minisig deleted file mode 100644 index efa349d..0000000 --- a/internal/test_data/server_list.json.forged_keyid.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: this signature was created with wrong_secret.key but has key ID changed to that of public.key -RURMm6vfaPgH35aarz3NMq4gbv6JvzOnjG003bDe6USu+HT/JzuxHjQcQGE/KBPdyCF6BDDwwFu+NVmi5jotYCJHWOEqSBU70gE= -trusted comment: timestamp:10 file:server_list.json hashed -3BWYJamM3t6ImuXQufTeO81UMZNyM7TujMu7SCmR+oapsSEBpmkazGOgzlJYR53HP9K9zrEA+4lV8gFFngooBA== diff --git a/internal/test_data/server_list.json.forged_pure.minisig b/internal/test_data/server_list.json.forged_pure.minisig deleted file mode 100644 index a362504..0000000 --- a/internal/test_data/server_list.json.forged_pure.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: this signature has ED changed to Ed -RWRMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: timestamp:10 file:server_list.json hashed -oK41aX7rmpbO2ohF3v3+JGgSexQaVlfWvYPzaKEkDlJm8mVZtuK/h26SCRuL6PbTR92DLZU59rw8ckICUH/ADw== diff --git a/internal/test_data/server_list.json.large_time.minisig b/internal/test_data/server_list.json.large_time.minisig deleted file mode 100644 index 79a2a52..0000000 --- a/internal/test_data/server_list.json.large_time.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: timestamp:4300000000 file:server_list.json -L9C58LIq7bTLf4otqW4Eb+ASL0+FM7nRRjstCBuCPtuUerFIsOqNUpDp2AQJJ4pZJKE7SkgIq2tV8/IaVpzxBQ== diff --git a/internal/test_data/server_list.json.minisig b/internal/test_data/server_list.json.minisig deleted file mode 100644 index 143585b..0000000 --- a/internal/test_data/server_list.json.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: timestamp:10 file:server_list.json hashed -oK41aX7rmpbO2ohF3v3+JGgSexQaVlfWvYPzaKEkDlJm8mVZtuK/h26SCRuL6PbTR92DLZU59rw8ckICUH/ADw== diff --git a/internal/test_data/server_list.json.pure.minisig b/internal/test_data/server_list.json.pure.minisig deleted file mode 100644 index 57dccfc..0000000 --- a/internal/test_data/server_list.json.pure.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RWRMm6vfaPgH3zQ/rcq2GMsNz1SYySz+olupm0I+nzNpOkPyUHTBwig3Pep4biOk/bH73bH+0sLNoZPcDk1f2Acn8JINc9MWMw4= -trusted comment: timestamp:10 file:server_list.json -FZ0eA96SlADsMrSOUgStQJpmUnBGpPbRvNI/oaYhKrylu6jUcXOgsRu6571mmDxYdlruSuUSlQbdmG81Qbl4AA== diff --git a/internal/test_data/server_list.json.tc_earliertime.minisig b/internal/test_data/server_list.json.tc_earliertime.minisig deleted file mode 100644 index 03da710..0000000 --- a/internal/test_data/server_list.json.tc_earliertime.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: timestamp:9 file:server_list.json hashed -vw3wjLDNZWoV98/GnFv38REiaeh+wUPEZgmBUvY35CEq00jDdHiJcYRV/7zBoKv+n9TAYxZ8WKUOGWNOPonTBg== diff --git a/internal/test_data/server_list.json.tc_emptyfile.minisig b/internal/test_data/server_list.json.tc_emptyfile.minisig deleted file mode 100644 index a7aa3ed..0000000 --- a/internal/test_data/server_list.json.tc_emptyfile.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: timestamp:10 file: hashed -g4drZ91TcYXNLnIGbeH5ZIFzrs2wWB9JTXjV3Jwg9ehSC2D8lCTqw3u2Rg+PvLPRvYmXTHyuJoKNWelsSh64CA== diff --git a/internal/test_data/server_list.json.tc_emptytime.minisig b/internal/test_data/server_list.json.tc_emptytime.minisig deleted file mode 100644 index d3ef01e..0000000 --- a/internal/test_data/server_list.json.tc_emptytime.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: timestamp: file:server_list.json hashed -lw5rnZsPi+TkZ4lOCy7bjsUgTXxG+jaGOGdHuNL95FSD2mmP9ZzEJPrJ2jnH7iYfkF3zDm0QvEUDxhEirlHBDA== diff --git a/internal/test_data/server_list.json.tc_latertime.minisig b/internal/test_data/server_list.json.tc_latertime.minisig deleted file mode 100644 index 8237123..0000000 --- a/internal/test_data/server_list.json.tc_latertime.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: timestamp:20 file:server_list.json hashed -rHcsHF2mmcZvDLreeuljVauuFULWiY8luCsxyBxxobcJkCedEDW3/RX5KeT+2NjHSFuQxkmrYOBWTY9+ECuUDQ== diff --git a/internal/test_data/server_list.json.tc_nofile.minisig b/internal/test_data/server_list.json.tc_nofile.minisig deleted file mode 100644 index 3c1dcbe..0000000 --- a/internal/test_data/server_list.json.tc_nofile.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: timestamp:10 hashed -NonaTZH7RDbsHXv85M7sL43YE7CTzs5qDRRoFYjajeqzHa+hdIuMGyemK85rAJ3prLGnMdWHkZhD4hsr3cZoDA== diff --git a/internal/test_data/server_list.json.tc_nohashed.minisig b/internal/test_data/server_list.json.tc_nohashed.minisig deleted file mode 100644 index 1d140c1..0000000 --- a/internal/test_data/server_list.json.tc_nohashed.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: timestamp:10 file:server_list.json -HaPGKT+Jqxjyw2Nt1GEKaPIZsAmVl/RI6p1mQ+S1LqzYicVgT5GxPs9NR6khdGGIFvo/xhVkXFceAWTRUCVQAg== diff --git a/internal/test_data/server_list.json.tc_notime.minisig b/internal/test_data/server_list.json.tc_notime.minisig deleted file mode 100644 index 39625c3..0000000 --- a/internal/test_data/server_list.json.tc_notime.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: file:server_list.json hashed -dMhb+0Y0KAO2tzI4g0ukL/VdMiLVopmXa9BS1RQBY8bYwzmebdIM4DAIZrhtO1avkpdy0prZehuhA1No6cOSAw== diff --git a/internal/test_data/server_list.json.tc_orglist.minisig b/internal/test_data/server_list.json.tc_orglist.minisig deleted file mode 100644 index 7c2a3a8..0000000 --- a/internal/test_data/server_list.json.tc_orglist.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: timestamp:10 file:organization_list.json hashed -NreDM4iGEjMWs5sfaJCGZBZ7D9QLqxBKJ/fVW2lvIDr249DSUNR4ZRca8UL73e3c9eTXgHnY/ojsjDtzxDScDw== diff --git a/internal/test_data/server_list.json.tc_otherfile.minisig b/internal/test_data/server_list.json.tc_otherfile.minisig deleted file mode 100644 index 58a29b2..0000000 --- a/internal/test_data/server_list.json.tc_otherfile.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: timestamp:10 file:otherfile hashed -PfDEIMlt2aNFyOnqHb45S7xm4fIg0vfUUbqXENPxry9GEZFX14c5BGtgcL/krDg8WFJHcIA5bzYcX58kgBiZCA== diff --git a/internal/test_data/server_list.json.tc_random.minisig b/internal/test_data/server_list.json.tc_random.minisig deleted file mode 100644 index 7240980..0000000 --- a/internal/test_data/server_list.json.tc_random.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= -trusted comment: random stuff -szGsyESH0EizTXH6n0yuQg6sHTKXr+TJW/Er9ZNJYgQV+1hVM+fc5q1EmVsJlA3kW4Rt/d1p9F0ShLIIgW2vAA== diff --git a/internal/test_data/server_list.json.wrong_key.minisig b/internal/test_data/server_list.json.wrong_key.minisig deleted file mode 100644 index 5a83c0e..0000000 --- a/internal/test_data/server_list.json.wrong_key.minisig +++ /dev/null @@ -1,4 +0,0 @@ -untrusted comment: signature from minisign secret key -RUTQvDHvQuYCCJaarz3NMq4gbv6JvzOnjG003bDe6USu+HT/JzuxHjQcQGE/KBPdyCF6BDDwwFu+NVmi5jotYCJHWOEqSBU70gE= -trusted comment: timestamp:10 file:server_list.json hashed -3BWYJamM3t6ImuXQufTeO81UMZNyM7TujMu7SCmR+oapsSEBpmkazGOgzlJYR53HP9K9zrEA+4lV8gFFngooBA== diff --git a/internal/test_data/wrong_public.key b/internal/test_data/wrong_public.key deleted file mode 100644 index aa794d4..0000000 --- a/internal/test_data/wrong_public.key +++ /dev/null @@ -1,2 +0,0 @@ -untrusted comment: minisign public key 802E642EF31BCD0 -RWTQvDHvQuYCCPDLi3UCXzj3BbzFM5QxUFfrp174iaqYo8lT0VaAkhOt diff --git a/internal/test_data/wrong_secret.key b/internal/test_data/wrong_secret.key deleted file mode 100644 index 68e9092..0000000 --- a/internal/test_data/wrong_secret.key +++ /dev/null @@ -1,2 +0,0 @@ -untrusted comment: minisign encrypted secret key -RWRTY0Iyrc2CTG2W1ZqEq9tb94oQWTnYUy4k8boMf13478FwlDYAAAACAAAAAAAAAEAAAAAA2gFhwOtjETu5WN1LpgtJHV1dk/7466LBJ8dgO/pZoQ3LLAYxlswJHVR/N/Q1HmmKvlxWo2jNTJcARXuHHlMTEgg1MERTldE88CqETrVbvq1JaqJlAY/HMkiqNEUR3L6+5VHbYPKXlVQ= diff --git a/internal/util.go b/internal/util.go deleted file mode 100644 index 02855c2..0000000 --- a/internal/util.go +++ /dev/null @@ -1,30 +0,0 @@ -package internal - -import ( - "crypto/rand" - "os" - "time" -) - -// Creates a random byteslice of `size` -func MakeRandomByteSlice(size int) ([]byte, error) { - byteSlice := make([]byte, size) - _, err := rand.Read(byteSlice) - if err != nil { - return nil, err - } - return byteSlice, nil -} - -func GenerateTimeSeconds() int64 { - current := time.Now() - return current.Unix() -} - -func EnsureDirectory(directory string) error { - mkdirErr := os.MkdirAll(directory, os.ModePerm) - if mkdirErr != nil { - return mkdirErr - } - return nil -} diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 0000000..4bdd1b5 --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,30 @@ +package util + +import ( + "crypto/rand" + "os" + "time" +) + +// Creates a random byteslice of `size` +func MakeRandomByteSlice(size int) ([]byte, error) { + byteSlice := make([]byte, size) + _, err := rand.Read(byteSlice) + if err != nil { + return nil, err + } + return byteSlice, nil +} + +func GenerateTimeSeconds() int64 { + current := time.Now() + return current.Unix() +} + +func EnsureDirectory(directory string) error { + mkdirErr := os.MkdirAll(directory, os.ModePerm) + if mkdirErr != nil { + return mkdirErr + } + return nil +} diff --git a/internal/verify.go b/internal/verify.go deleted file mode 100644 index 713e4d7..0000000 --- a/internal/verify.go +++ /dev/null @@ -1,205 +0,0 @@ -package internal - -import ( - "errors" - "fmt" - "os" - - "github.com/jedisct1/go-minisign" -) - -// getKeys returns keys taken from https://git.sr.ht/~eduvpn/disco.eduvpn.org#public-keys. -func getKeys() []string { - return []string{ - "RWRtBSX1alxyGX+Xn3LuZnWUT0w//B6EmTJvgaAxBMYzlQeI+jdrO6KF", // fkooman@tuxed.net, kolla@uninett.no - "RWQKqtqvd0R7rUDp0rWzbtYPA3towPWcLDCl7eY9pBMMI/ohCmrS0WiM", // RoSp - } -} - -// Verify verifies the signature (.minisig file format) on signedJson. -// -// expectedFileName must be set to the file type to be verified, either "server_list.json" or "organization_list.json". -// minSign must be set to the minimum UNIX timestamp (without milliseconds) for the file version. -// This value should not be smaller than the time on the previous document verified. -// forcePrehash indicates whether or not we want to force the use of prehashed signatures -// In the future we want to remove this parameter and only allow prehashed signatures -// -// The return value will either be (true, nil) for a valid signature or (false, VerifyError) otherwise. -// -// Verify is a wrapper around verifyWithKeys where allowedPublicKeys is set to the list from https://git.sr.ht/~eduvpn/disco.eduvpn.org#public-keys. -func Verify(signatureFileContent string, signedJson []byte, expectedFileName string, minSignTime uint64, forcePrehash bool) (bool, error) { - keyStrs := getKeys() - if extraKey != "" { - keyStrs = append(keyStrs, extraKey) - _, err := fmt.Fprintf(os.Stderr, "INSECURE TEST MODE ENABLED WITH KEY %q\n", extraKey) - if err != nil { - panic(err) - } - } - valid, err := verifyWithKeys(signatureFileContent, signedJson, expectedFileName, minSignTime, keyStrs, forcePrehash) - if err != nil { - var verifyCreatePublickeyError *VerifyCreatePublicKeyError - if errors.As(err, &verifyCreatePublickeyError) { - panic(err) // This should not happen unless keyStrs has an invalid key - } - return valid, err - } - return valid, nil -} - -// extraKey is an extra allowed key for testing. -var extraKey = "" - -// InsecureTestingSetExtraKey adds an extra allowed key for verification with Verify. -// ONLY USE FOR TESTING. Applies to all threads. Probably not thread-safe. Do not call in parallel to Verify. -// -// keyString must be a Base64-encoded Minisign key, or empty to reset. -func InsecureTestingSetExtraKey(keyString string) { - extraKey = keyString -} - -// verifyWithKeys verifies the Minisign signature in signatureFileContent (minisig file format) over the server_list/organization_list JSON in signedJson. -// -// Verification is performed using a matching key in allowedPublicKeys. -// The signature is checked to be a Ed25519 Minisign (optionally Ed25519 Blake2b-512 prehashed, see forcePrehash) signature with a valid trusted comment. -// The file type that is verified is indicated by expectedFileName, which must be one of "server_list.json"/"organization_list.json". -// The trusted comment is checked to be of the form "timestamp:\tfile:", optionally suffixed by something, e.g. "\thashed". -// The signature is checked to have a timestamp with a value of at least minSignTime, which is a UNIX timestamp without milliseconds. -// -// The return value will either be (true, nil) on success or (false, detailedVerifyError) on failure. -func verifyWithKeys(signatureFileContent string, signedJson []byte, filename string, minSignTime uint64, allowedPublicKeys []string, forcePrehash bool) (bool, error) { - switch filename { - case "server_list.json", "organization_list.json": - break - default: - return false, &VerifyUnknownExpectedFilenameError{Filename: filename, Expected: "server_list.json or organization_list.json"} - } - - sig, err := minisign.DecodeSignature(signatureFileContent) - if err != nil { - return false, &VerifyInvalidSignatureFormatError{Err: err} - } - - // Check if signature is prehashed, see https://jedisct1.github.io/minisign/#signature-format - if forcePrehash && sig.SignatureAlgorithm != [2]byte{'E', 'D'} { - return false, &VerifyInvalidSignatureAlgorithmError{Algorithm: string(sig.SignatureAlgorithm[:]), WantedAlgorithm: "ED (BLAKE2b-prehashed EdDSA)"} - } - - // Find allowed key used for signature - for _, keyStr := range allowedPublicKeys { - key, err := minisign.NewPublicKey(keyStr) - if err != nil { - // Should only happen if Verify is wrong or extraKey is invalid - return false, &VerifyCreatePublicKeyError{PublicKey: keyStr, Err: err} - } - - if sig.KeyId != key.KeyId { - continue // Wrong key - } - - valid, err := key.Verify(signedJson, sig) - if !valid { - return false, &VerifyInvalidSignatureError{Err: err} - } - - // Parse trusted comment - var signTime uint64 - var sigFileName string - // sigFileName cannot have spaces - _, err = fmt.Sscanf(sig.TrustedComment, "trusted comment: timestamp:%d\tfile:%s", &signTime, &sigFileName) - if err != nil { - return false, &VerifyInvalidTrustedCommentError{TrustedComment: sig.TrustedComment, Err: err} - } - - if sigFileName != filename { - return false, &VerifyWrongSigFilenameError{Filename: filename, SigFilename: sigFileName} - } - - if signTime < minSignTime { - return false, &VerifySigTimeEarlierError{SigTime: signTime, MinSigTime: minSignTime} - } - - return true, nil - } - - // No matching allowed key found - return false, &VerifyUnknownKeyError{Filename: filename} -} - -type VerifyUnknownExpectedFilenameError struct { - Filename string - Expected string -} - -func (e *VerifyUnknownExpectedFilenameError) Error() string { - return fmt.Sprintf("invalid filename: %s, expected: %s", e.Filename, e.Expected) -} - -type VerifyInvalidSignatureFormatError struct { - Err error -} - -func (e *VerifyInvalidSignatureFormatError) Error() string { - return fmt.Sprintf("invalid signature format with error: %v", e.Err) -} - -type VerifyInvalidSignatureAlgorithmError struct { - Algorithm string - WantedAlgorithm string -} - -func (e *VerifyInvalidSignatureAlgorithmError) Error() string { - return fmt.Sprintf("invalid signature algorithm: %s, wanted: %s", e.Algorithm, e.WantedAlgorithm) -} - -type VerifyCreatePublicKeyError struct { - PublicKey string - Err error -} - -func (e *VerifyCreatePublicKeyError) Error() string { - return fmt.Sprintf("failed to create public key: %s with error: %v", e.PublicKey, e.Err) -} - -type VerifyInvalidSignatureError struct { - Err error -} - -func (e *VerifyInvalidSignatureError) Error() string { - return fmt.Sprintf("invalid signature with error: %v", e.Err) -} - -type VerifyInvalidTrustedCommentError struct { - TrustedComment string - Err error -} - -func (e *VerifyInvalidTrustedCommentError) Error() string { - return fmt.Sprintf("invalid trusted comment: %s with error: %v", e.TrustedComment, e.Err) -} - -type VerifyWrongSigFilenameError struct { - Filename string - SigFilename string -} - -func (e *VerifyWrongSigFilenameError) Error() string { - return fmt.Sprintf("wrong filename: %s, expected filename: %s for signature", e.Filename, e.SigFilename) -} - -type VerifySigTimeEarlierError struct { - SigTime uint64 - MinSigTime uint64 -} - -func (e *VerifySigTimeEarlierError) Error() string { - return fmt.Sprintf("Sign time: %d is earlier than sign time: %d", e.SigTime, e.MinSigTime) -} - -type VerifyUnknownKeyError struct { - Filename string -} - -func (e *VerifyUnknownKeyError) Error() string { - return fmt.Sprintf("signature for filename: %s was created with an unknown key", e.Filename) -} diff --git a/internal/verify/test_data/empty b/internal/verify/test_data/empty new file mode 100644 index 0000000..e69de29 diff --git a/internal/verify/test_data/generate.sh b/internal/verify/test_data/generate.sh new file mode 100644 index 0000000..b1b4545 --- /dev/null +++ b/internal/verify/test_data/generate.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Generate testcases with fake keys + +# Make sure we do not delete *.minisigs etc. in the wrong directory +if [ ${PWD##*/} != "test_data" ] +then + >&2 echo "Wrong directory, should be run in test_data/" + exit 1 +fi + +rm -f *.minisig *.blake2b + +# Uncomment to regenerate keys +#rm -f *.key +#echo -en "\n\n" | minisign -Gf -p public.key -s secret.key & +#echo -en "\n\n" | minisign -Gf -p wrong_public.key -s wrong_secret.key & +#wait + +# Try to create pure signature with default Minisign (works with version < 0.10) +echo | minisign -Sm server_list.json -x server_list.json.pure.minisig -t $'timestamp:10\tfile:server_list.json' -s secret.key +# Check if it is actually a prehashed signature +if echo | minisign -VHm server_list.json -x server_list.json.pure.minisig -p public.key +then + echo "minisign version is >0.9, trying minisign-0.9" + # If it is, try to sign with some minisign-0.9 program + if ! echo | minisign-0.9 -Sm server_list.json -x server_list.json.pure.minisig -t $'timestamp:10\tfile:server_list.json' -s secret.key + then + >&2 echo -e "\n\nTo produce a non-prehashed signature we need Minisign 0.9\n\n" + fi +fi + +# Rest works with Minisign 0.9 and 0.10 (and up, probably) + +echo | minisign -SHm server_list.json -t $'timestamp:10\tfile:server_list.json\thashed' -s secret.key & +echo | minisign -SHm server_list.json -x server_list.json.tc_nohashed.minisig -t $'timestamp:10\tfile:server_list.json' -s secret.key & +echo | minisign -SHm server_list.json -x server_list.json.tc_latertime.minisig -t $'timestamp:20\tfile:server_list.json\t hashed' -s secret.key & +echo | minisign -SHm server_list.json -x server_list.json.tc_orglist.minisig -t $'timestamp:10\tfile:organization_list.json\thashed' -s secret.key & +wait +echo | minisign -SHm server_list.json -x server_list.json.tc_otherfile.minisig -t $'timestamp:10\tfile:otherfile\thashed' -s secret.key & +echo | minisign -SHm server_list.json -x server_list.json.tc_nofile.minisig -t $'timestamp:10\thashed' -s secret.key & +echo | minisign -SHm server_list.json -x server_list.json.tc_notime.minisig -t $'file:server_list.json\thashed' -s secret.key & +echo | minisign -SHm server_list.json -x server_list.json.tc_emptytime.minisig -t $'timestamp:\tfile:server_list.json\thashed' -s secret.key & +wait +echo | minisign -SHm server_list.json -x server_list.json.tc_emptyfile.minisig -t $'timestamp:10\tfile:\thashed' -s secret.key & +echo | minisign -SHm server_list.json -x server_list.json.tc_earliertime.minisig -t $'timestamp:9\tfile:server_list.json\thashed' -s secret.key & +echo | minisign -SHm server_list.json -x server_list.json.tc_random.minisig -t 'random stuff' -s secret.key & +echo | minisign -SHm server_list.json -x server_list.json.large_time.minisig -t $'timestamp:4300000000\tfile:server_list.json' -s secret.key & +wait + +echo | minisign -SHm organization_list.json -t $'timestamp:10\tfile:organization_list.json\thashed' -s secret.key & +echo | minisign -SHm organization_list.json -x organization_list.json.tc_servlist.minisig -t $'timestamp:10\tfile:server_list.json\thashed' -s secret.key & + +echo | minisign -SHm other_list.json -t $'timestamp:10\tfile:other_list.json\thashed' -s secret.key & + +echo | minisign -SHm server_list.json -x server_list.json.wrong_key.minisig -t $'timestamp:10\tfile:server_list.json\thashed' -s wrong_secret.key & +wait + +./generate_forged.py diff --git a/internal/verify/test_data/generate_forged.py b/internal/verify/test_data/generate_forged.py new file mode 100644 index 0000000..9d42adc --- /dev/null +++ b/internal/verify/test_data/generate_forged.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 + +import hashlib +import base64 + +# Hash server_list.json + +with open("server_list.json", "rb") as f: + b = f.read() + +with open("server_list.json.blake2b", "wb") as f: + f.write(hashlib.blake2b(b).digest()) + +# Forge pure signature on hash, see https://github.com/jedisct1/minisign/issues/104 + +with open("server_list.json.minisig", "rb") as f: + siglines = f.readlines() + +siglines[0] = b"untrusted comment: this signature has ED changed to Ed\n" +sig = base64.b64decode(siglines[1]) +siglines[1] = base64.b64encode(b"Ed" + sig[2:]) + b"\n" + +with open("server_list.json.forged_pure.minisig", "wb") as f: + f.writelines(siglines) + # Should now work: minisign -Vm server_list.json.blake2b -x server_list.json.forged_pure.minisig -p public-key + +# Try to forge key ID + +with open("server_list.json.wrong_key.minisig", "rb") as f: + siglines = f.readlines() + +siglines[ + 0 +] = b"untrusted comment: this signature was created with wrong_secret.key but has key ID changed to that of public.key\n" +sig_wrong = base64.b64decode(siglines[1]) +siglines[1] = ( + base64.b64encode(sig_wrong[:2] + sig[2 : 2 + 8] + sig_wrong[2 + 8 :]) + b"\n" +) + +with open("server_list.json.forged_keyid.minisig", "wb") as f: + f.writelines(siglines) diff --git a/internal/verify/test_data/organization_list.json b/internal/verify/test_data/organization_list.json new file mode 100644 index 0000000..8c53044 --- /dev/null +++ b/internal/verify/test_data/organization_list.json @@ -0,0 +1 @@ +{"organization_list": [{}]} \ No newline at end of file diff --git a/internal/verify/test_data/organization_list.json.minisig b/internal/verify/test_data/organization_list.json.minisig new file mode 100644 index 0000000..1fa546e --- /dev/null +++ b/internal/verify/test_data/organization_list.json.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH31cHjNvTEh+TCqDVCwUgFVZoRdgWYAaQDxH3L3UIsRi9Qb1O4vLI4V1CYPatKzXZnSodSJM/AZgl9v7l/5bfPQ0= +trusted comment: timestamp:10 file:organization_list.json hashed +21zZv1DviMpLCdv1NgzLBl6d+F1ZllSNyjAquYxhTHGcs2F64bDFpqY0I0xjCHIoXly6HKqJKIBXNgud12ijCQ== diff --git a/internal/verify/test_data/organization_list.json.tc_servlist.minisig b/internal/verify/test_data/organization_list.json.tc_servlist.minisig new file mode 100644 index 0000000..a7fe41f --- /dev/null +++ b/internal/verify/test_data/organization_list.json.tc_servlist.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH31cHjNvTEh+TCqDVCwUgFVZoRdgWYAaQDxH3L3UIsRi9Qb1O4vLI4V1CYPatKzXZnSodSJM/AZgl9v7l/5bfPQ0= +trusted comment: timestamp:10 file:server_list.json hashed +R6hjM/oMS5LAvpYM4F6E7iUpnlPxqiY0QfuOnpum31CW0sUy/Ypy2PiomSwvZXKVR7keEZS/+lZjyra9TkrLDQ== diff --git a/internal/verify/test_data/other_list.json b/internal/verify/test_data/other_list.json new file mode 100644 index 0000000..25ba1a8 --- /dev/null +++ b/internal/verify/test_data/other_list.json @@ -0,0 +1 @@ +{"other_list": [{}]} \ No newline at end of file diff --git a/internal/verify/test_data/other_list.json.minisig b/internal/verify/test_data/other_list.json.minisig new file mode 100644 index 0000000..eaa2248 --- /dev/null +++ b/internal/verify/test_data/other_list.json.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH366C1RnYeUAgEeX/S5A1Z9qmkV2+GJaVj06FWGd4aMLc+HS7iFMhG69u3TVD4YmzMH12rk7hQrnyCC6ex8ypIQA= +trusted comment: timestamp:10 file:other_list.json hashed +26+608n+bjQF9lwNdXbIK6t5bP8dzhjNQ9hACeYJLiB2tr437Aec2GkmJh0jSiRv1QV4RYBcKJeHQBUcV2grCQ== diff --git a/internal/verify/test_data/public.key b/internal/verify/test_data/public.key new file mode 100644 index 0000000..72676d3 --- /dev/null +++ b/internal/verify/test_data/public.key @@ -0,0 +1,2 @@ +untrusted comment: minisign public key DF07F868DFAB9B4C +RWRMm6vfaPgH39iT++NBiUKZim2nDWnalgkNROovPbZdSwVFgUdKU4ac diff --git a/internal/verify/test_data/random.txt b/internal/verify/test_data/random.txt new file mode 100644 index 0000000..b6fc4c6 --- /dev/null +++ b/internal/verify/test_data/random.txt @@ -0,0 +1 @@ +hello \ No newline at end of file diff --git a/internal/verify/test_data/secret.key b/internal/verify/test_data/secret.key new file mode 100644 index 0000000..6e4af37 --- /dev/null +++ b/internal/verify/test_data/secret.key @@ -0,0 +1,2 @@ +untrusted comment: minisign encrypted secret key +RWRTY0IyobkTOt4ugAHNTPB6zOxHgX8spW6HQWddB5IrdCPDAgsAAAACAAAAAAAAAEAAAAAAvK1S1gsOgozZHuIdLWXq1IwxnWVr+dlySiykTbO6F85HvzPtgxZ7oLcGkT/vPdskAh0SV9H2ylHlt9oarXcWNDKs2r6EcZw/qy5FsD+5uhPfxwWV4qDF+1G456tYDYID63d50CgzdO0= diff --git a/internal/verify/test_data/server_list.json b/internal/verify/test_data/server_list.json new file mode 100644 index 0000000..67c4c8d --- /dev/null +++ b/internal/verify/test_data/server_list.json @@ -0,0 +1,3 @@ +{ +"server_list": [{}] +} \ No newline at end of file diff --git a/internal/verify/test_data/server_list.json.blake2b b/internal/verify/test_data/server_list.json.blake2b new file mode 100644 index 0000000..5d2ca5a Binary files /dev/null and b/internal/verify/test_data/server_list.json.blake2b differ diff --git a/internal/verify/test_data/server_list.json.forged_keyid.minisig b/internal/verify/test_data/server_list.json.forged_keyid.minisig new file mode 100644 index 0000000..efa349d --- /dev/null +++ b/internal/verify/test_data/server_list.json.forged_keyid.minisig @@ -0,0 +1,4 @@ +untrusted comment: this signature was created with wrong_secret.key but has key ID changed to that of public.key +RURMm6vfaPgH35aarz3NMq4gbv6JvzOnjG003bDe6USu+HT/JzuxHjQcQGE/KBPdyCF6BDDwwFu+NVmi5jotYCJHWOEqSBU70gE= +trusted comment: timestamp:10 file:server_list.json hashed +3BWYJamM3t6ImuXQufTeO81UMZNyM7TujMu7SCmR+oapsSEBpmkazGOgzlJYR53HP9K9zrEA+4lV8gFFngooBA== diff --git a/internal/verify/test_data/server_list.json.forged_pure.minisig b/internal/verify/test_data/server_list.json.forged_pure.minisig new file mode 100644 index 0000000..a362504 --- /dev/null +++ b/internal/verify/test_data/server_list.json.forged_pure.minisig @@ -0,0 +1,4 @@ +untrusted comment: this signature has ED changed to Ed +RWRMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: timestamp:10 file:server_list.json hashed +oK41aX7rmpbO2ohF3v3+JGgSexQaVlfWvYPzaKEkDlJm8mVZtuK/h26SCRuL6PbTR92DLZU59rw8ckICUH/ADw== diff --git a/internal/verify/test_data/server_list.json.large_time.minisig b/internal/verify/test_data/server_list.json.large_time.minisig new file mode 100644 index 0000000..79a2a52 --- /dev/null +++ b/internal/verify/test_data/server_list.json.large_time.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: timestamp:4300000000 file:server_list.json +L9C58LIq7bTLf4otqW4Eb+ASL0+FM7nRRjstCBuCPtuUerFIsOqNUpDp2AQJJ4pZJKE7SkgIq2tV8/IaVpzxBQ== diff --git a/internal/verify/test_data/server_list.json.minisig b/internal/verify/test_data/server_list.json.minisig new file mode 100644 index 0000000..143585b --- /dev/null +++ b/internal/verify/test_data/server_list.json.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: timestamp:10 file:server_list.json hashed +oK41aX7rmpbO2ohF3v3+JGgSexQaVlfWvYPzaKEkDlJm8mVZtuK/h26SCRuL6PbTR92DLZU59rw8ckICUH/ADw== diff --git a/internal/verify/test_data/server_list.json.pure.minisig b/internal/verify/test_data/server_list.json.pure.minisig new file mode 100644 index 0000000..57dccfc --- /dev/null +++ b/internal/verify/test_data/server_list.json.pure.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RWRMm6vfaPgH3zQ/rcq2GMsNz1SYySz+olupm0I+nzNpOkPyUHTBwig3Pep4biOk/bH73bH+0sLNoZPcDk1f2Acn8JINc9MWMw4= +trusted comment: timestamp:10 file:server_list.json +FZ0eA96SlADsMrSOUgStQJpmUnBGpPbRvNI/oaYhKrylu6jUcXOgsRu6571mmDxYdlruSuUSlQbdmG81Qbl4AA== diff --git a/internal/verify/test_data/server_list.json.tc_earliertime.minisig b/internal/verify/test_data/server_list.json.tc_earliertime.minisig new file mode 100644 index 0000000..03da710 --- /dev/null +++ b/internal/verify/test_data/server_list.json.tc_earliertime.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: timestamp:9 file:server_list.json hashed +vw3wjLDNZWoV98/GnFv38REiaeh+wUPEZgmBUvY35CEq00jDdHiJcYRV/7zBoKv+n9TAYxZ8WKUOGWNOPonTBg== diff --git a/internal/verify/test_data/server_list.json.tc_emptyfile.minisig b/internal/verify/test_data/server_list.json.tc_emptyfile.minisig new file mode 100644 index 0000000..a7aa3ed --- /dev/null +++ b/internal/verify/test_data/server_list.json.tc_emptyfile.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: timestamp:10 file: hashed +g4drZ91TcYXNLnIGbeH5ZIFzrs2wWB9JTXjV3Jwg9ehSC2D8lCTqw3u2Rg+PvLPRvYmXTHyuJoKNWelsSh64CA== diff --git a/internal/verify/test_data/server_list.json.tc_emptytime.minisig b/internal/verify/test_data/server_list.json.tc_emptytime.minisig new file mode 100644 index 0000000..d3ef01e --- /dev/null +++ b/internal/verify/test_data/server_list.json.tc_emptytime.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: timestamp: file:server_list.json hashed +lw5rnZsPi+TkZ4lOCy7bjsUgTXxG+jaGOGdHuNL95FSD2mmP9ZzEJPrJ2jnH7iYfkF3zDm0QvEUDxhEirlHBDA== diff --git a/internal/verify/test_data/server_list.json.tc_latertime.minisig b/internal/verify/test_data/server_list.json.tc_latertime.minisig new file mode 100644 index 0000000..8237123 --- /dev/null +++ b/internal/verify/test_data/server_list.json.tc_latertime.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: timestamp:20 file:server_list.json hashed +rHcsHF2mmcZvDLreeuljVauuFULWiY8luCsxyBxxobcJkCedEDW3/RX5KeT+2NjHSFuQxkmrYOBWTY9+ECuUDQ== diff --git a/internal/verify/test_data/server_list.json.tc_nofile.minisig b/internal/verify/test_data/server_list.json.tc_nofile.minisig new file mode 100644 index 0000000..3c1dcbe --- /dev/null +++ b/internal/verify/test_data/server_list.json.tc_nofile.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: timestamp:10 hashed +NonaTZH7RDbsHXv85M7sL43YE7CTzs5qDRRoFYjajeqzHa+hdIuMGyemK85rAJ3prLGnMdWHkZhD4hsr3cZoDA== diff --git a/internal/verify/test_data/server_list.json.tc_nohashed.minisig b/internal/verify/test_data/server_list.json.tc_nohashed.minisig new file mode 100644 index 0000000..1d140c1 --- /dev/null +++ b/internal/verify/test_data/server_list.json.tc_nohashed.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: timestamp:10 file:server_list.json +HaPGKT+Jqxjyw2Nt1GEKaPIZsAmVl/RI6p1mQ+S1LqzYicVgT5GxPs9NR6khdGGIFvo/xhVkXFceAWTRUCVQAg== diff --git a/internal/verify/test_data/server_list.json.tc_notime.minisig b/internal/verify/test_data/server_list.json.tc_notime.minisig new file mode 100644 index 0000000..39625c3 --- /dev/null +++ b/internal/verify/test_data/server_list.json.tc_notime.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: file:server_list.json hashed +dMhb+0Y0KAO2tzI4g0ukL/VdMiLVopmXa9BS1RQBY8bYwzmebdIM4DAIZrhtO1avkpdy0prZehuhA1No6cOSAw== diff --git a/internal/verify/test_data/server_list.json.tc_orglist.minisig b/internal/verify/test_data/server_list.json.tc_orglist.minisig new file mode 100644 index 0000000..7c2a3a8 --- /dev/null +++ b/internal/verify/test_data/server_list.json.tc_orglist.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: timestamp:10 file:organization_list.json hashed +NreDM4iGEjMWs5sfaJCGZBZ7D9QLqxBKJ/fVW2lvIDr249DSUNR4ZRca8UL73e3c9eTXgHnY/ojsjDtzxDScDw== diff --git a/internal/verify/test_data/server_list.json.tc_otherfile.minisig b/internal/verify/test_data/server_list.json.tc_otherfile.minisig new file mode 100644 index 0000000..58a29b2 --- /dev/null +++ b/internal/verify/test_data/server_list.json.tc_otherfile.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: timestamp:10 file:otherfile hashed +PfDEIMlt2aNFyOnqHb45S7xm4fIg0vfUUbqXENPxry9GEZFX14c5BGtgcL/krDg8WFJHcIA5bzYcX58kgBiZCA== diff --git a/internal/verify/test_data/server_list.json.tc_random.minisig b/internal/verify/test_data/server_list.json.tc_random.minisig new file mode 100644 index 0000000..7240980 --- /dev/null +++ b/internal/verify/test_data/server_list.json.tc_random.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURMm6vfaPgH3997FX/cHwhXJpcluwbNiznrfYV83WS/Gsd3BeO/g10Mo7Z9N5rMSXcpGrmT2CagiEEm5zSw/MEnTqs4YWICdQs= +trusted comment: random stuff +szGsyESH0EizTXH6n0yuQg6sHTKXr+TJW/Er9ZNJYgQV+1hVM+fc5q1EmVsJlA3kW4Rt/d1p9F0ShLIIgW2vAA== diff --git a/internal/verify/test_data/server_list.json.wrong_key.minisig b/internal/verify/test_data/server_list.json.wrong_key.minisig new file mode 100644 index 0000000..5a83c0e --- /dev/null +++ b/internal/verify/test_data/server_list.json.wrong_key.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RUTQvDHvQuYCCJaarz3NMq4gbv6JvzOnjG003bDe6USu+HT/JzuxHjQcQGE/KBPdyCF6BDDwwFu+NVmi5jotYCJHWOEqSBU70gE= +trusted comment: timestamp:10 file:server_list.json hashed +3BWYJamM3t6ImuXQufTeO81UMZNyM7TujMu7SCmR+oapsSEBpmkazGOgzlJYR53HP9K9zrEA+4lV8gFFngooBA== diff --git a/internal/verify/test_data/wrong_public.key b/internal/verify/test_data/wrong_public.key new file mode 100644 index 0000000..aa794d4 --- /dev/null +++ b/internal/verify/test_data/wrong_public.key @@ -0,0 +1,2 @@ +untrusted comment: minisign public key 802E642EF31BCD0 +RWTQvDHvQuYCCPDLi3UCXzj3BbzFM5QxUFfrp174iaqYo8lT0VaAkhOt diff --git a/internal/verify/test_data/wrong_secret.key b/internal/verify/test_data/wrong_secret.key new file mode 100644 index 0000000..68e9092 --- /dev/null +++ b/internal/verify/test_data/wrong_secret.key @@ -0,0 +1,2 @@ +untrusted comment: minisign encrypted secret key +RWRTY0Iyrc2CTG2W1ZqEq9tb94oQWTnYUy4k8boMf13478FwlDYAAAACAAAAAAAAAEAAAAAA2gFhwOtjETu5WN1LpgtJHV1dk/7466LBJ8dgO/pZoQ3LLAYxlswJHVR/N/Q1HmmKvlxWo2jNTJcARXuHHlMTEgg1MERTldE88CqETrVbvq1JaqJlAY/HMkiqNEUR3L6+5VHbYPKXlVQ= diff --git a/internal/verify/verify.go b/internal/verify/verify.go new file mode 100644 index 0000000..2d53b2e --- /dev/null +++ b/internal/verify/verify.go @@ -0,0 +1,205 @@ +package verify + +import ( + "errors" + "fmt" + "os" + + "github.com/jedisct1/go-minisign" +) + +// getKeys returns keys taken from https://git.sr.ht/~eduvpn/disco.eduvpn.org#public-keys. +func getKeys() []string { + return []string{ + "RWRtBSX1alxyGX+Xn3LuZnWUT0w//B6EmTJvgaAxBMYzlQeI+jdrO6KF", // fkooman@tuxed.net, kolla@uninett.no + "RWQKqtqvd0R7rUDp0rWzbtYPA3towPWcLDCl7eY9pBMMI/ohCmrS0WiM", // RoSp + } +} + +// Verify verifies the signature (.minisig file format) on signedJson. +// +// expectedFileName must be set to the file type to be verified, either "server_list.json" or "organization_list.json". +// minSign must be set to the minimum UNIX timestamp (without milliseconds) for the file version. +// This value should not be smaller than the time on the previous document verified. +// forcePrehash indicates whether or not we want to force the use of prehashed signatures +// In the future we want to remove this parameter and only allow prehashed signatures +// +// The return value will either be (true, nil) for a valid signature or (false, VerifyError) otherwise. +// +// Verify is a wrapper around verifyWithKeys where allowedPublicKeys is set to the list from https://git.sr.ht/~eduvpn/disco.eduvpn.org#public-keys. +func Verify(signatureFileContent string, signedJson []byte, expectedFileName string, minSignTime uint64, forcePrehash bool) (bool, error) { + keyStrs := getKeys() + if extraKey != "" { + keyStrs = append(keyStrs, extraKey) + _, err := fmt.Fprintf(os.Stderr, "INSECURE TEST MODE ENABLED WITH KEY %q\n", extraKey) + if err != nil { + panic(err) + } + } + valid, err := verifyWithKeys(signatureFileContent, signedJson, expectedFileName, minSignTime, keyStrs, forcePrehash) + if err != nil { + var verifyCreatePublickeyError *VerifyCreatePublicKeyError + if errors.As(err, &verifyCreatePublickeyError) { + panic(err) // This should not happen unless keyStrs has an invalid key + } + return valid, err + } + return valid, nil +} + +// extraKey is an extra allowed key for testing. +var extraKey = "" + +// InsecureTestingSetExtraKey adds an extra allowed key for verification with Verify. +// ONLY USE FOR TESTING. Applies to all threads. Probably not thread-safe. Do not call in parallel to Verify. +// +// keyString must be a Base64-encoded Minisign key, or empty to reset. +func InsecureTestingSetExtraKey(keyString string) { + extraKey = keyString +} + +// verifyWithKeys verifies the Minisign signature in signatureFileContent (minisig file format) over the server_list/organization_list JSON in signedJson. +// +// Verification is performed using a matching key in allowedPublicKeys. +// The signature is checked to be a Ed25519 Minisign (optionally Ed25519 Blake2b-512 prehashed, see forcePrehash) signature with a valid trusted comment. +// The file type that is verified is indicated by expectedFileName, which must be one of "server_list.json"/"organization_list.json". +// The trusted comment is checked to be of the form "timestamp:\tfile:", optionally suffixed by something, e.g. "\thashed". +// The signature is checked to have a timestamp with a value of at least minSignTime, which is a UNIX timestamp without milliseconds. +// +// The return value will either be (true, nil) on success or (false, detailedVerifyError) on failure. +func verifyWithKeys(signatureFileContent string, signedJson []byte, filename string, minSignTime uint64, allowedPublicKeys []string, forcePrehash bool) (bool, error) { + switch filename { + case "server_list.json", "organization_list.json": + break + default: + return false, &VerifyUnknownExpectedFilenameError{Filename: filename, Expected: "server_list.json or organization_list.json"} + } + + sig, err := minisign.DecodeSignature(signatureFileContent) + if err != nil { + return false, &VerifyInvalidSignatureFormatError{Err: err} + } + + // Check if signature is prehashed, see https://jedisct1.github.io/minisign/#signature-format + if forcePrehash && sig.SignatureAlgorithm != [2]byte{'E', 'D'} { + return false, &VerifyInvalidSignatureAlgorithmError{Algorithm: string(sig.SignatureAlgorithm[:]), WantedAlgorithm: "ED (BLAKE2b-prehashed EdDSA)"} + } + + // Find allowed key used for signature + for _, keyStr := range allowedPublicKeys { + key, err := minisign.NewPublicKey(keyStr) + if err != nil { + // Should only happen if Verify is wrong or extraKey is invalid + return false, &VerifyCreatePublicKeyError{PublicKey: keyStr, Err: err} + } + + if sig.KeyId != key.KeyId { + continue // Wrong key + } + + valid, err := key.Verify(signedJson, sig) + if !valid { + return false, &VerifyInvalidSignatureError{Err: err} + } + + // Parse trusted comment + var signTime uint64 + var sigFileName string + // sigFileName cannot have spaces + _, err = fmt.Sscanf(sig.TrustedComment, "trusted comment: timestamp:%d\tfile:%s", &signTime, &sigFileName) + if err != nil { + return false, &VerifyInvalidTrustedCommentError{TrustedComment: sig.TrustedComment, Err: err} + } + + if sigFileName != filename { + return false, &VerifyWrongSigFilenameError{Filename: filename, SigFilename: sigFileName} + } + + if signTime < minSignTime { + return false, &VerifySigTimeEarlierError{SigTime: signTime, MinSigTime: minSignTime} + } + + return true, nil + } + + // No matching allowed key found + return false, &VerifyUnknownKeyError{Filename: filename} +} + +type VerifyUnknownExpectedFilenameError struct { + Filename string + Expected string +} + +func (e *VerifyUnknownExpectedFilenameError) Error() string { + return fmt.Sprintf("invalid filename: %s, expected: %s", e.Filename, e.Expected) +} + +type VerifyInvalidSignatureFormatError struct { + Err error +} + +func (e *VerifyInvalidSignatureFormatError) Error() string { + return fmt.Sprintf("invalid signature format with error: %v", e.Err) +} + +type VerifyInvalidSignatureAlgorithmError struct { + Algorithm string + WantedAlgorithm string +} + +func (e *VerifyInvalidSignatureAlgorithmError) Error() string { + return fmt.Sprintf("invalid signature algorithm: %s, wanted: %s", e.Algorithm, e.WantedAlgorithm) +} + +type VerifyCreatePublicKeyError struct { + PublicKey string + Err error +} + +func (e *VerifyCreatePublicKeyError) Error() string { + return fmt.Sprintf("failed to create public key: %s with error: %v", e.PublicKey, e.Err) +} + +type VerifyInvalidSignatureError struct { + Err error +} + +func (e *VerifyInvalidSignatureError) Error() string { + return fmt.Sprintf("invalid signature with error: %v", e.Err) +} + +type VerifyInvalidTrustedCommentError struct { + TrustedComment string + Err error +} + +func (e *VerifyInvalidTrustedCommentError) Error() string { + return fmt.Sprintf("invalid trusted comment: %s with error: %v", e.TrustedComment, e.Err) +} + +type VerifyWrongSigFilenameError struct { + Filename string + SigFilename string +} + +func (e *VerifyWrongSigFilenameError) Error() string { + return fmt.Sprintf("wrong filename: %s, expected filename: %s for signature", e.Filename, e.SigFilename) +} + +type VerifySigTimeEarlierError struct { + SigTime uint64 + MinSigTime uint64 +} + +func (e *VerifySigTimeEarlierError) Error() string { + return fmt.Sprintf("Sign time: %d is earlier than sign time: %d", e.SigTime, e.MinSigTime) +} + +type VerifyUnknownKeyError struct { + Filename string +} + +func (e *VerifyUnknownKeyError) Error() string { + return fmt.Sprintf("signature for filename: %s was created with an unknown key", e.Filename) +} diff --git a/internal/verify/verify_test.go b/internal/verify/verify_test.go new file mode 100644 index 0000000..7d577dd --- /dev/null +++ b/internal/verify/verify_test.go @@ -0,0 +1,140 @@ +package verify + +import ( + "bufio" + "errors" + "fmt" + "io/ioutil" + "os" + "testing" +) + +func Test_verifyWithKeys(t *testing.T) { + var err error + + var pk []string + { + file, err := os.Open("test_data/public.key") + if err != nil { + panic(err) + } + defer file.Close() + + // Get last line (key string) from file + scanner := bufio.NewScanner(file) + for i := 0; i < 2; i++ { + if !scanner.Scan() { + panic(scanner.Err()) + } + } + pk = []string{scanner.Text()} + } + + var ( + verifyCreatePublicKeyError *VerifyCreatePublicKeyError + verifyInvalidSignatureAlgorithmError *VerifyInvalidSignatureAlgorithmError + verifyWrongSigFilenameError *VerifyWrongSigFilenameError + verifyInvalidTrustedCommentError *VerifyInvalidTrustedCommentError + verifyInvalidSignatureFormatError *VerifyInvalidSignatureFormatError + verifyInvalidSignatureError *VerifyInvalidSignatureError + verifySigTimeEarlierError *VerifySigTimeEarlierError + verifyUnknownExpectedFilenameError *VerifyUnknownExpectedFilenameError + verifyUnknownKeyError *VerifyUnknownKeyError + ) + + tests := []struct { + expectedErr interface{} + testName string + signatureFile string + jsonFile string + expectedFileName string + minSignTime uint64 + allowedPks []string + }{ + {&verifyInvalidSignatureAlgorithmError, "pure", "server_list.json.pure.minisig", "server_list.json", "server_list.json", 10, pk}, + + {nil, "valid server_list", "server_list.json.minisig", "server_list.json", "server_list.json", 10, pk}, + {nil, "TC no hashed", "server_list.json.tc_nohashed.minisig", "server_list.json", "server_list.json", 10, pk}, + {nil, "TC later time", "server_list.json.tc_latertime.minisig", "server_list.json", "server_list.json", 10, pk}, + {&verifyWrongSigFilenameError, "server_list TC file:organization_list", "server_list.json.tc_orglist.minisig", "server_list.json", "server_list.json", 10, pk}, + {&verifyWrongSigFilenameError, "organization_list as server_list", "organization_list.json.minisig", "organization_list.json", "server_list.json", 10, pk}, + {&verifyWrongSigFilenameError, "TC file:otherfile", "server_list.json.tc_otherfile.minisig", "server_list.json", "server_list.json", 10, pk}, + {&verifySigTimeEarlierError, "TC no file", "server_list.json.tc_nofile.minisig", "server_list.json", "server_list.json", 10, pk}, + {&verifySigTimeEarlierError, "TC no time", "server_list.json.tc_notime.minisig", "server_list.json", "server_list.json", 10, pk}, + {&verifySigTimeEarlierError, "TC empty time", "server_list.json.tc_emptytime.minisig", "server_list.json", "server_list.json", 10, pk}, + {&verifyInvalidSignatureFormatError, "TC empty file", "server_list.json.tc_emptyfile.minisig", "server_list.json", "server_list.json", 10, pk}, + {&verifyInvalidTrustedCommentError, "TC random", "server_list.json.tc_random.minisig", "server_list.json", "server_list.json", 10, pk}, + {nil, "large time", "server_list.json.large_time.minisig", "server_list.json", "server_list.json", 43e8, pk}, + {nil, "lower min time", "server_list.json.minisig", "server_list.json", "server_list.json", 5, pk}, + {&verifySigTimeEarlierError, "higher min time", "server_list.json.minisig", "server_list.json", "server_list.json", 11, pk}, + + {nil, "valid organization_list", "organization_list.json.minisig", "organization_list.json", "organization_list.json", 10, pk}, + {&verifyWrongSigFilenameError, "organization_list TC file:server_list", "organization_list.json.tc_servlist.minisig", "organization_list.json", "organization_list.json", 10, pk}, + {&verifyWrongSigFilenameError, "server_list as organization_list", "server_list.json.minisig", "server_list.json", "organization_list.json", 10, pk}, + + {&verifyUnknownExpectedFilenameError, "valid other_list", "other_list.json.minisig", "other_list.json", "other_list.json", 10, pk}, + {&verifyWrongSigFilenameError, "other_list as server_list", "other_list.json.minisig", "other_list.json", "server_list.json", 10, pk}, + + {&verifyInvalidSignatureFormatError, "invalid signature file", "random.txt", "server_list.json", "server_list.json", 10, pk}, + {&verifyInvalidSignatureFormatError, "empty signature file", "empty", "server_list.json", "server_list.json", 10, pk}, + + {&verifyUnknownKeyError, "wrong key", "server_list.json.wrong_key.minisig", "server_list.json", "server_list.json", 10, pk}, + + {&verifyInvalidSignatureAlgorithmError, "forged pure signature", "server_list.json.forged_pure.minisig", "server_list.json.blake2b", "server_list.json", 10, pk}, + {&verifyInvalidSignatureError, "forged key ID", "server_list.json.forged_keyid.minisig", "server_list.json", "server_list.json", 10, pk}, + + {&verifyUnknownKeyError, "no allowed keys", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{}}, + {nil, "multiple allowed keys 1", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{ + pk[0], "RWSf0PYToIUJmDlsz21YOXvgQzHj9NSdyJUqEY5ZdfS9GepeXt3+JJRZ", + }}, + {nil, "multiple allowed keys 2", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{ + "RWSf0PYToIUJmDlsz21YOXvgQzHj9NSdyJUqEY5ZdfS9GepeXt3+JJRZ", pk[0], + }}, + {&verifyCreatePublicKeyError, "invalid allowed key", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{"AAA"}}, + } + + // Cache file contents in map, mapping file names to contents + files := map[string][]byte{} + loadFile := func(name string) { + content, loaded := files[name] + if !loaded { + content, err = ioutil.ReadFile("test_data/" + name) + if err != nil { + panic(err) + } + files[name] = content + } + } + for _, test := range tests { + loadFile(test.signatureFile) + loadFile(test.jsonFile) + } + + forcePrehash := true + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + t.Parallel() + valid, err := verifyWithKeys(string(files[tt.signatureFile]), files[tt.jsonFile], + tt.expectedFileName, tt.minSignTime, tt.allowedPks, forcePrehash) + compareResults(t, valid, err, tt.expectedErr, func() string { + return fmt.Sprintf("verifyWithKeys(%q, %q, %q, %v, %v, %t)", + tt.signatureFile, tt.jsonFile, tt.expectedFileName, tt.minSignTime, tt.allowedPks, forcePrehash) + }) + }) + } +} + +// compareResults compares returned ret, err from a verify function with expected error code expected. +// callStr is called to get the formatted parameters passed to the function. +func compareResults(t *testing.T, ret bool, err error, expectedErr interface{}, callStr func() string) { + // different error returned + if expectedErr != nil && !errors.As(err, expectedErr) { + t.Errorf("%v\nerror %T = %v, wantErr %T", callStr(), err, err, expectedErr) + return + } + // different boolean returned + expectedBool := expectedErr == nil + if ret != expectedBool { + t.Errorf("%v\n= %v, want %v", callStr(), ret, expectedBool) + } +} diff --git a/internal/verify_test.go b/internal/verify_test.go deleted file mode 100644 index f980dc2..0000000 --- a/internal/verify_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package internal - -import ( - "bufio" - "errors" - "fmt" - "io/ioutil" - "os" - "testing" -) - -func Test_verifyWithKeys(t *testing.T) { - var err error - - var pk []string - { - file, err := os.Open("test_data/public.key") - if err != nil { - panic(err) - } - defer file.Close() - - // Get last line (key string) from file - scanner := bufio.NewScanner(file) - for i := 0; i < 2; i++ { - if !scanner.Scan() { - panic(scanner.Err()) - } - } - pk = []string{scanner.Text()} - } - - var ( - verifyCreatePublicKeyError *VerifyCreatePublicKeyError - verifyInvalidSignatureAlgorithmError *VerifyInvalidSignatureAlgorithmError - verifyWrongSigFilenameError *VerifyWrongSigFilenameError - verifyInvalidTrustedCommentError *VerifyInvalidTrustedCommentError - verifyInvalidSignatureFormatError *VerifyInvalidSignatureFormatError - verifyInvalidSignatureError *VerifyInvalidSignatureError - verifySigTimeEarlierError *VerifySigTimeEarlierError - verifyUnknownExpectedFilenameError *VerifyUnknownExpectedFilenameError - verifyUnknownKeyError *VerifyUnknownKeyError - ) - - tests := []struct { - expectedErr interface{} - testName string - signatureFile string - jsonFile string - expectedFileName string - minSignTime uint64 - allowedPks []string - }{ - {&verifyInvalidSignatureAlgorithmError, "pure", "server_list.json.pure.minisig", "server_list.json", "server_list.json", 10, pk}, - - {nil, "valid server_list", "server_list.json.minisig", "server_list.json", "server_list.json", 10, pk}, - {nil, "TC no hashed", "server_list.json.tc_nohashed.minisig", "server_list.json", "server_list.json", 10, pk}, - {nil, "TC later time", "server_list.json.tc_latertime.minisig", "server_list.json", "server_list.json", 10, pk}, - {&verifyWrongSigFilenameError, "server_list TC file:organization_list", "server_list.json.tc_orglist.minisig", "server_list.json", "server_list.json", 10, pk}, - {&verifyWrongSigFilenameError, "organization_list as server_list", "organization_list.json.minisig", "organization_list.json", "server_list.json", 10, pk}, - {&verifyWrongSigFilenameError, "TC file:otherfile", "server_list.json.tc_otherfile.minisig", "server_list.json", "server_list.json", 10, pk}, - {&verifySigTimeEarlierError, "TC no file", "server_list.json.tc_nofile.minisig", "server_list.json", "server_list.json", 10, pk}, - {&verifySigTimeEarlierError, "TC no time", "server_list.json.tc_notime.minisig", "server_list.json", "server_list.json", 10, pk}, - {&verifySigTimeEarlierError, "TC empty time", "server_list.json.tc_emptytime.minisig", "server_list.json", "server_list.json", 10, pk}, - {&verifyInvalidSignatureFormatError, "TC empty file", "server_list.json.tc_emptyfile.minisig", "server_list.json", "server_list.json", 10, pk}, - {&verifyInvalidTrustedCommentError, "TC random", "server_list.json.tc_random.minisig", "server_list.json", "server_list.json", 10, pk}, - {nil, "large time", "server_list.json.large_time.minisig", "server_list.json", "server_list.json", 43e8, pk}, - {nil, "lower min time", "server_list.json.minisig", "server_list.json", "server_list.json", 5, pk}, - {&verifySigTimeEarlierError, "higher min time", "server_list.json.minisig", "server_list.json", "server_list.json", 11, pk}, - - {nil, "valid organization_list", "organization_list.json.minisig", "organization_list.json", "organization_list.json", 10, pk}, - {&verifyWrongSigFilenameError, "organization_list TC file:server_list", "organization_list.json.tc_servlist.minisig", "organization_list.json", "organization_list.json", 10, pk}, - {&verifyWrongSigFilenameError, "server_list as organization_list", "server_list.json.minisig", "server_list.json", "organization_list.json", 10, pk}, - - {&verifyUnknownExpectedFilenameError, "valid other_list", "other_list.json.minisig", "other_list.json", "other_list.json", 10, pk}, - {&verifyWrongSigFilenameError, "other_list as server_list", "other_list.json.minisig", "other_list.json", "server_list.json", 10, pk}, - - {&verifyInvalidSignatureFormatError, "invalid signature file", "random.txt", "server_list.json", "server_list.json", 10, pk}, - {&verifyInvalidSignatureFormatError, "empty signature file", "empty", "server_list.json", "server_list.json", 10, pk}, - - {&verifyUnknownKeyError, "wrong key", "server_list.json.wrong_key.minisig", "server_list.json", "server_list.json", 10, pk}, - - {&verifyInvalidSignatureAlgorithmError, "forged pure signature", "server_list.json.forged_pure.minisig", "server_list.json.blake2b", "server_list.json", 10, pk}, - {&verifyInvalidSignatureError, "forged key ID", "server_list.json.forged_keyid.minisig", "server_list.json", "server_list.json", 10, pk}, - - {&verifyUnknownKeyError, "no allowed keys", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{}}, - {nil, "multiple allowed keys 1", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{ - pk[0], "RWSf0PYToIUJmDlsz21YOXvgQzHj9NSdyJUqEY5ZdfS9GepeXt3+JJRZ", - }}, - {nil, "multiple allowed keys 2", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{ - "RWSf0PYToIUJmDlsz21YOXvgQzHj9NSdyJUqEY5ZdfS9GepeXt3+JJRZ", pk[0], - }}, - {&verifyCreatePublicKeyError, "invalid allowed key", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{"AAA"}}, - } - - // Cache file contents in map, mapping file names to contents - files := map[string][]byte{} - loadFile := func(name string) { - content, loaded := files[name] - if !loaded { - content, err = ioutil.ReadFile("test_data/" + name) - if err != nil { - panic(err) - } - files[name] = content - } - } - for _, test := range tests { - loadFile(test.signatureFile) - loadFile(test.jsonFile) - } - - forcePrehash := true - for _, tt := range tests { - t.Run(tt.testName, func(t *testing.T) { - t.Parallel() - valid, err := verifyWithKeys(string(files[tt.signatureFile]), files[tt.jsonFile], - tt.expectedFileName, tt.minSignTime, tt.allowedPks, forcePrehash) - compareResults(t, valid, err, tt.expectedErr, func() string { - return fmt.Sprintf("verifyWithKeys(%q, %q, %q, %v, %v, %t)", - tt.signatureFile, tt.jsonFile, tt.expectedFileName, tt.minSignTime, tt.allowedPks, forcePrehash) - }) - }) - } -} - -// compareResults compares returned ret, err from a verify function with expected error code expected. -// callStr is called to get the formatted parameters passed to the function. -func compareResults(t *testing.T, ret bool, err error, expectedErr interface{}, callStr func() string) { - // different error returned - if expectedErr != nil && !errors.As(err, expectedErr) { - t.Errorf("%v\nerror %T = %v, wantErr %T", callStr(), err, err, expectedErr) - return - } - // different boolean returned - expectedBool := expectedErr == nil - if ret != expectedBool { - t.Errorf("%v\n= %v, want %v", callStr(), ret, expectedBool) - } -} diff --git a/internal/wireguard.go b/internal/wireguard.go deleted file mode 100644 index 00c9467..0000000 --- a/internal/wireguard.go +++ /dev/null @@ -1,82 +0,0 @@ -package internal - -import ( - "fmt" - "regexp" - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" -) - -func wireguardGenerateKey() (wgtypes.Key, error) { - key, keyErr := wgtypes.GeneratePrivateKey() - - if keyErr != nil { - return key, &WireguardGenerateKeyError{Err: keyErr} - } - return key, nil -} - -// FIXME: Instead of doing a regex replace, decide if we should use a parser -func wireguardConfigAddKey(config string, key wgtypes.Key) string { - interface_section := "[Interface]" - interface_section_escaped := regexp.QuoteMeta(interface_section) - - // (?m) enables multi line mode - // ^ match from beginning of line - // $ match till end of line - // So it matches [Interface] section exactly - interface_re := regexp.MustCompile(fmt.Sprintf("(?m)^%s$", interface_section_escaped)) - to_replace := fmt.Sprintf("%s\nPrivateKey = %s", interface_section, key.String()) - return interface_re.ReplaceAllString(config, to_replace) -} - -func WireguardGetConfig(server Server, supportsOpenVPN bool) (string, string, error) { - base, baseErr := server.GetBase() - - if baseErr != nil { - return "", "", &WireguardGetConfigError{Err: baseErr} - } - - profile_id := base.Profiles.Current - wireguardKey, wireguardErr := wireguardGenerateKey() - - if wireguardErr != nil { - return "", "", &WireguardGetConfigError{Err: wireguardErr} - } - - wireguardPublicKey := wireguardKey.PublicKey().String() - config, content, expires, configErr := APIConnectWireguard(server, profile_id, wireguardPublicKey, supportsOpenVPN) - - if configErr != nil { - return "", "", &WireguardGetConfigError{Err: wireguardErr} - } - - // Store start and end time - base.StartTime = GenerateTimeSeconds() - base.EndTime = expires - - if content == "wireguard" { - // This needs the go code a way to identify a connection - // Use the uuid of the connection e.g. on Linux - // This needs the client code to call the go code - - config = wireguardConfigAddKey(config, wireguardKey) - } - - return config, content, nil -} - -type WireguardGenerateKeyError struct { - Err error -} - -func (e *WireguardGenerateKeyError) Error() string { - return fmt.Sprintf("failed generating Wireguard key with error: %v", e.Err) -} - -type WireguardGetConfigError struct { - Err error -} - -func (e *WireguardGetConfigError) Error() string { - return fmt.Sprintf("failed getting Wireguard config with error: %v", e.Err) -} diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go new file mode 100644 index 0000000..db20067 --- /dev/null +++ b/internal/wireguard/wireguard.go @@ -0,0 +1,38 @@ +package wireguard + +import ( + "fmt" + "regexp" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +func GenerateKey() (wgtypes.Key, error) { + key, keyErr := wgtypes.GeneratePrivateKey() + + if keyErr != nil { + return key, &WireguardGenerateKeyError{Err: keyErr} + } + return key, nil +} + +// FIXME: Instead of doing a regex replace, decide if we should use a parser +func ConfigAddKey(config string, key wgtypes.Key) string { + interface_section := "[Interface]" + interface_section_escaped := regexp.QuoteMeta(interface_section) + + // (?m) enables multi line mode + // ^ match from beginning of line + // $ match till end of line + // So it matches [Interface] section exactly + interface_re := regexp.MustCompile(fmt.Sprintf("(?m)^%s$", interface_section_escaped)) + to_replace := fmt.Sprintf("%s\nPrivateKey = %s", interface_section, key.String()) + return interface_re.ReplaceAllString(config, to_replace) +} + +type WireguardGenerateKeyError struct { + Err error +} + +func (e *WireguardGenerateKeyError) Error() string { + return fmt.Sprintf("failed generating Wireguard key with error: %v", e.Err) +} -- cgit v1.2.3