summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api.go110
-rw-r--r--internal/config.go43
-rw-r--r--internal/discovery.go172
-rw-r--r--internal/fsm.go187
-rw-r--r--internal/http.go172
-rw-r--r--internal/log.go65
-rw-r--r--internal/oauth.go379
-rw-r--r--internal/openvpn.go12
-rw-r--r--internal/server.go196
-rw-r--r--internal/test_data/empty0
-rw-r--r--internal/test_data/generate.sh58
-rw-r--r--internal/test_data/generate_forged.py37
-rw-r--r--internal/test_data/organization_list.json1
-rw-r--r--internal/test_data/organization_list.json.minisig4
-rw-r--r--internal/test_data/organization_list.json.tc_servlist.minisig4
-rw-r--r--internal/test_data/other_list.json1
-rw-r--r--internal/test_data/other_list.json.minisig4
-rw-r--r--internal/test_data/public.key2
-rw-r--r--internal/test_data/random.txt1
-rw-r--r--internal/test_data/secret.key2
-rw-r--r--internal/test_data/server_list.json3
-rw-r--r--internal/test_data/server_list.json.blake2bbin0 -> 64 bytes
-rw-r--r--internal/test_data/server_list.json.forged_keyid.minisig4
-rw-r--r--internal/test_data/server_list.json.forged_pure.minisig4
-rw-r--r--internal/test_data/server_list.json.large_time.minisig4
-rw-r--r--internal/test_data/server_list.json.minisig4
-rw-r--r--internal/test_data/server_list.json.pure.minisig4
-rw-r--r--internal/test_data/server_list.json.tc_earliertime.minisig4
-rw-r--r--internal/test_data/server_list.json.tc_emptyfile.minisig4
-rw-r--r--internal/test_data/server_list.json.tc_emptytime.minisig4
-rw-r--r--internal/test_data/server_list.json.tc_latertime.minisig4
-rw-r--r--internal/test_data/server_list.json.tc_nofile.minisig4
-rw-r--r--internal/test_data/server_list.json.tc_nohashed.minisig4
-rw-r--r--internal/test_data/server_list.json.tc_notime.minisig4
-rw-r--r--internal/test_data/server_list.json.tc_orglist.minisig4
-rw-r--r--internal/test_data/server_list.json.tc_otherfile.minisig4
-rw-r--r--internal/test_data/server_list.json.tc_random.minisig4
-rw-r--r--internal/test_data/server_list.json.wrong_key.minisig4
-rw-r--r--internal/test_data/wrong_public.key2
-rw-r--r--internal/test_data/wrong_secret.key2
-rw-r--r--internal/util.go30
-rw-r--r--internal/verify.go205
-rw-r--r--internal/verify_test.go140
-rw-r--r--internal/wireguard.go52
44 files changed, 1948 insertions, 0 deletions
diff --git a/internal/api.go b/internal/api.go
new file mode 100644
index 0000000..718702b
--- /dev/null
+++ b/internal/api.go
@@ -0,0 +1,110 @@
+package internal
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+)
+
+// Authenticated wrappers on top of HTTP
+func (server *Server) apiAuthenticated(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{}
+ }
+ url := server.Endpoints.API.V3.API + endpoint
+
+ // Ensure we have valid tokens
+ oauthErr := server.EnsureTokens()
+
+ if oauthErr != nil {
+ return nil, nil, oauthErr
+ }
+
+ headerKey := "Authorization"
+ headerValue := fmt.Sprintf("Bearer %s", server.OAuth.Token.Access)
+ if opts.Headers != nil {
+ opts.Headers.Add(headerKey, headerValue)
+ } else {
+ opts.Headers = http.Header{headerKey: {headerValue}}
+ }
+ return HTTPMethodWithOpts(method, url, opts)
+}
+
+func (server *Server) apiAuthenticatedRetry(method string, endpoint string, opts *HTTPOptionalParams) (http.Header, []byte, error) {
+ header, body, bodyErr := server.apiAuthenticated(method, endpoint, opts)
+ if bodyErr != nil {
+ var error *HTTPStatusError
+
+ // Only retry authenticated if we get a HTTP 401
+ if errors.As(bodyErr, &error) && error.Status == 401 {
+ server.Logger.Log(LOG_INFO, fmt.Sprintf("API: Got HTTP error %v, retrying authenticated", error))
+ // Tell the method that the token is expired
+ server.OAuth.Token.ExpiredTimestamp = GenerateTimeSeconds()
+ return server.apiAuthenticated(method, endpoint, opts)
+ }
+ return header, nil, bodyErr
+ }
+ return header, body, bodyErr
+}
+
+func (server *Server) APIInfo() error {
+ _, body, bodyErr := server.apiAuthenticatedRetry(http.MethodGet, "/info", nil)
+ if bodyErr != nil {
+ return bodyErr
+ }
+ structure := ServerProfileInfo{}
+ jsonErr := json.Unmarshal(body, &structure)
+
+ if jsonErr != nil {
+ return jsonErr
+ }
+
+ server.Profiles = structure
+ server.ProfilesRaw = string(body)
+ return nil
+}
+
+func (server *Server) APIConnectWireguard(profile_id string, pubkey string) (string, string, error) {
+ headers := http.Header{
+ "content-type": {"application/x-www-form-urlencoded"},
+ "accept": {"application/x-wireguard-profile"},
+ }
+
+ urlForm := url.Values{
+ "profile_id": {profile_id},
+ "public_key": {pubkey},
+ }
+ header, connectBody, connectErr := server.apiAuthenticatedRetry(http.MethodPost, "/connect", &HTTPOptionalParams{Headers: headers, Body: urlForm})
+ if connectErr != nil {
+ return "", "", connectErr
+ }
+
+ expires := header.Get("expires")
+ return string(connectBody), expires, nil
+}
+
+func (server *Server) APIConnectOpenVPN(profile_id string) (string, string, 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 := server.apiAuthenticatedRetry(http.MethodPost, "/connect", &HTTPOptionalParams{Headers: headers, Body: urlForm})
+ if connectErr != nil {
+ return "", "", connectErr
+ }
+
+ expires := header.Get("expires")
+ return string(connectBody), expires, nil
+}
+
+// This needs no further return value as it's best effort
+func (server *Server) APIDisconnect() {
+ server.apiAuthenticatedRetry(http.MethodPost, "/disconnect", nil)
+}
diff --git a/internal/config.go b/internal/config.go
new file mode 100644
index 0000000..47f773e
--- /dev/null
+++ b/internal/config.go
@@ -0,0 +1,43 @@
+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 configDirErr
+ }
+ jsonString, marshalErr := json.Marshal(readStruct)
+ if marshalErr != nil {
+ return marshalErr
+ }
+ return ioutil.WriteFile(config.GetFilename(), jsonString, 0o644)
+}
+
+func (config *Config) Load(writeStruct interface{}) error {
+ bytes, readErr := ioutil.ReadFile(config.GetFilename())
+ if readErr != nil {
+ return readErr
+ }
+ return json.Unmarshal(bytes, writeStruct)
+}
diff --git a/internal/discovery.go b/internal/discovery.go
new file mode 100644
index 0000000..8c0acc7
--- /dev/null
+++ b/internal/discovery.go
@@ -0,0 +1,172 @@
+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/fsm.go b/internal/fsm.go
new file mode 100644
index 0000000..fadc7c9
--- /dev/null
+++ b/internal/fsm.go
@@ -0,0 +1,187 @@
+package internal
+
+import (
+ "fmt"
+ "os"
+ "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
+
+ // Authenticated means the OAuth process has finished and the user is now authenticated with the server
+ AUTHENTICATED
+
+ // 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 AUTHENTICATED:
+ return "Authenticated"
+ 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
+ StateCallback func(string, string, string)
+ Logger *FileLogger
+ Debug bool
+}
+
+func (fsm *FSM) Init(callback func(string, string, string), logger *FileLogger, debug bool) {
+ fsm.States = FSMStates{
+ DEREGISTERED: {{NO_SERVER, "Client registers"}},
+ NO_SERVER: {{CHOSEN_SERVER, "User chooses a server"}},
+ CHOSEN_SERVER: {{AUTHENTICATED, "Found tokens in config"}, {OAUTH_STARTED, "No tokens found in config"}},
+ OAUTH_STARTED: {{AUTHENTICATED, "User authorizes with browser"}},
+ AUTHENTICATED: {{OAUTH_STARTED, "Re-authenticate with OAuth"}, {REQUEST_CONFIG, "Client requests a config"}},
+ REQUEST_CONFIG: {{ASK_PROFILE, "Multiple profiles found"}, {HAS_CONFIG, "Success, only one profile"}},
+ ASK_PROFILE: {{HAS_CONFIG, "User chooses profile and success"}},
+ HAS_CONFIG: {{CONNECTED, "OS reports connected"}},
+ CONNECTED: {{AUTHENTICATED, "OS reports disconnected"}},
+ }
+ fsm.Current = DEREGISTERED
+ fsm.StateCallback = callback
+ fsm.Logger = logger
+ 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) writeGraph() {
+ graph := fsm.GenerateGraph()
+
+ f, err := os.Create("debug.graph")
+ if err != nil {
+ fsm.Logger.Log(LOG_INFO, fmt.Sprintf("Failed to write debug fsm graph with error %v", err))
+ }
+
+ defer f.Close()
+
+ f.WriteString(graph)
+}
+
+func (fsm *FSM) GoTransitionWithData(newState FSMStateID, data string) bool {
+ ok := fsm.HasTransition(newState)
+
+ if ok {
+ oldState := fsm.Current
+ fsm.Current = newState
+ if fsm.Debug {
+ fsm.writeGraph()
+ }
+ fsm.StateCallback(oldState.String(), newState.String(), data)
+ }
+
+ return ok
+}
+
+func (fsm *FSM) GoTransition(newState FSMStateID) bool {
+ return fsm.GoTransitionWithData(newState, "")
+}
+
+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()
+}
diff --git a/internal/http.go b/internal/http.go
new file mode 100644
index 0000000..8ca8cb9
--- /dev/null
+++ b/internal/http.go
@@ -0,0 +1,172 @@
+package internal
+
+import (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+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 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, err := url.Parse(baseURL)
+ if err != nil {
+ return "", err
+ }
+
+ 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 {
+ 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
+}
diff --git a/internal/log.go b/internal/log.go
new file mode 100644
index 0000000..52c0f3d
--- /dev/null
+++ b/internal/log.go
@@ -0,0 +1,65 @@
+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 configDirErr
+ }
+ logFile, logOpenErr := os.OpenFile(logger.getFilename(directory, name), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666)
+ if logOpenErr != nil {
+ return logOpenErr
+ }
+ log.SetOutput(logFile)
+ 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()
+}
diff --git a/internal/oauth.go b/internal/oauth.go
new file mode 100644
index 0000000..1e728ca
--- /dev/null
+++ b/internal/oauth.go
@@ -0,0 +1,379 @@
+package internal
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "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 "", &OAuthGenStateUnableError{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 "", &OAuthGenVerifierUnableError{Err: err}
+ }
+
+ return base64.RawURLEncoding.EncodeToString(randomBytes), nil
+}
+
+type OAuth struct {
+ Session OAuthExchangeSession `json:"-"`
+ Token OAuthToken `json:"token"`
+ 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 authenticated 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 &OAuthFailedCallbackError{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 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 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 = &OAuthFailedCallbackParameterError{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 = &OAuthFailedCallbackParameterError{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 = &OAuthFailedCallbackStateMatchError{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 = &OAuthFailedCallbackGetTokensError{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) Init(fsm *FSM, logger *FileLogger) {
+ oauth.FSM = fsm
+ oauth.Logger = logger
+}
+
+// Starts the OAuth exchange for eduvpn.
+func (oauth *OAuth) start(name string, authorizationURL string, tokenURL string) error {
+ if !oauth.FSM.HasTransition(OAUTH_STARTED) {
+ return errors.New(fmt.Sprintf("Failed starting oauth, invalid state %s", oauth.FSM.Current.String()))
+ }
+ // Generate the state
+ state, stateErr := genState()
+ if stateErr != nil {
+ return &OAuthFailedInitializeError{Err: stateErr}
+ }
+
+ // Generate the verifier and challenge
+ verifier, verifierErr := genVerifier()
+ if verifierErr != nil {
+ return &OAuthFailedInitializeError{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(authorizationURL, parameters)
+
+ if urlErr != nil { // shouldn't happen
+ panic(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.TokenURL = tokenURL
+ oauth.Session = oauthSession
+ oauth.FSM.GoTransitionWithData(OAUTH_STARTED, authURL)
+ return nil
+}
+
+// Error definitions
+func (oauth *OAuth) Finish() error {
+ if !oauth.FSM.HasTransition(AUTHENTICATED) {
+ return errors.New("invalid state to finish oauth")
+ }
+ tokenErr := oauth.getTokensWithCallback()
+ if tokenErr != nil {
+ return tokenErr
+ }
+ oauth.FSM.GoTransition(AUTHENTICATED)
+ return nil
+}
+
+func (oauth *OAuth) Login(name string, authorizationURL string, tokenURL string) error {
+ authInitializeErr := oauth.start(name, authorizationURL, tokenURL)
+
+ if authInitializeErr != nil {
+ return authInitializeErr
+ }
+
+ oauthErr := oauth.Finish()
+
+ if oauthErr != nil {
+ return 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 OAuthGenStateUnableError struct {
+ Err error
+}
+
+func (e *OAuthGenStateUnableError) Error() string {
+ return fmt.Sprintf("failed generating state with error %v", e.Err)
+}
+
+type OAuthGenVerifierUnableError struct {
+ Err error
+}
+
+func (e *OAuthGenVerifierUnableError) Error() string {
+ return fmt.Sprintf("failed generating verifier with error %v", e.Err)
+}
+
+type OAuthFailedCallbackError struct {
+ Addr string
+ Err error
+}
+
+func (e *OAuthFailedCallbackError) Error() string {
+ return fmt.Sprintf("failed callback %s with error %v", e.Addr, e.Err)
+}
+
+type OAuthFailedCallbackParameterError struct {
+ Parameter string
+ URL string
+}
+
+func (e *OAuthFailedCallbackParameterError) Error() string {
+ return fmt.Sprintf("failed retrieving parameter %s in url %s", e.Parameter, e.URL)
+}
+
+type OAuthFailedCallbackStateMatchError struct {
+ State string
+ ExpectedState string
+}
+
+func (e *OAuthFailedCallbackStateMatchError) Error() string {
+ return fmt.Sprintf("failed matching state, got %s, want %s", e.State, e.ExpectedState)
+}
+
+type OAuthFailedCallbackGetTokensError struct {
+ Err error
+}
+
+func (e *OAuthFailedCallbackGetTokensError) Error() string {
+ return fmt.Sprintf("failed getting tokens with error %v", e.Err)
+}
+
+type OAuthFailedInitializeError struct {
+ Err error
+}
+
+func (e *OAuthFailedInitializeError) Error() string {
+ return fmt.Sprintf("failed initializing OAuth with error %v", e.Err)
+}
diff --git a/internal/openvpn.go b/internal/openvpn.go
new file mode 100644
index 0000000..1b2e626
--- /dev/null
+++ b/internal/openvpn.go
@@ -0,0 +1,12 @@
+package internal
+
+func (server *Server) OpenVPNGetConfig() (string, error) {
+ profile_id := server.Profiles.Current
+ configOpenVPN, _, configErr := server.APIConnectOpenVPN(profile_id)
+
+ if configErr != nil {
+ return "", configErr
+ }
+
+ return configOpenVPN, nil
+}
diff --git a/internal/server.go b/internal/server.go
new file mode 100644
index 0000000..eb7f8fe
--- /dev/null
+++ b/internal/server.go
@@ -0,0 +1,196 @@
+package internal
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+)
+
+type Server struct {
+ BaseURL string `json:"base_url"`
+ Endpoints ServerEndpoints `json:"endpoints"`
+ OAuth OAuth `json:"oauth"`
+ Profiles ServerProfileInfo `json:"profiles"`
+ ProfilesRaw string `json:"profiles_raw"`
+ Logger *FileLogger `json:"-"`
+ FSM *FSM `json:"-"`
+}
+
+type Servers struct {
+ List map[string]*Server `json:"list"`
+ Current string `json:"current"`
+}
+
+func (servers *Servers) GetCurrentServer() (*Server, error) {
+ if servers.List == nil {
+ return nil, errors.New("No map found to get Current Server")
+ }
+ server, exists := servers.List[servers.Current]
+
+ if !exists || server == nil {
+ return nil, errors.New("Current Server not found")
+ }
+ return server, nil
+}
+
+func (server *Server) Init(url string, fsm *FSM, logger *FileLogger) error {
+ server.BaseURL = url
+ server.FSM = fsm
+ server.Logger = logger
+ server.OAuth.Init(fsm, logger)
+ endpointsErr := server.GetEndpoints()
+ if endpointsErr != nil {
+ return endpointsErr
+ }
+ return nil
+}
+
+func (server *Server) EnsureTokens() error {
+ if server.OAuth.NeedsRelogin() {
+ server.Logger.Log(LOG_INFO, "OAuth: Tokens are invalid, relogging in")
+ return server.Login()
+ }
+ return nil
+}
+
+func (servers *Servers) EnsureServer(url string, fsm *FSM, logger *FileLogger) *Server {
+ if servers.List == nil {
+ servers.List = make(map[string]*Server)
+ }
+
+ server, exists := servers.List[url]
+
+ if !exists || server == nil {
+ server = &Server{}
+ }
+ server.Init(url, fsm, logger)
+ servers.List[url] = server
+ servers.Current = url
+ return server
+}
+
+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"`
+}
+
+func (server *Server) Login() error {
+ return server.OAuth.Login("org.eduvpn.app.linux", server.Endpoints.API.V3.Authorization, server.Endpoints.API.V3.Token)
+}
+
+func (server *Server) NeedsRelogin() bool {
+ // Check if OAuth needs relogin
+ return server.OAuth.NeedsRelogin()
+}
+
+func (server *Server) GetEndpoints() error {
+ url := server.BaseURL + "/.well-known/vpn-user-portal"
+ _, body, bodyErr := HTTPGet(url)
+
+ if bodyErr != nil {
+ return bodyErr
+ }
+
+ endpoints := ServerEndpoints{}
+ jsonErr := json.Unmarshal(body, &endpoints)
+
+ if jsonErr != nil {
+ return jsonErr
+ }
+
+ server.Endpoints = endpoints
+
+ return nil
+}
+
+func (profile *ServerProfile) supportsWireguard() bool {
+ for _, proto := range profile.VPNProtoList {
+ if proto == "wireguard" {
+ return true
+ }
+ }
+ return false
+}
+
+func (server *Server) getCurrentProfile() (*ServerProfile, error) {
+ profile_id := server.Profiles.Current
+ for _, profile := range server.Profiles.Info.ProfileList {
+ if profile.ID == profile_id {
+ return &profile, nil
+ }
+ }
+ return nil, errors.New("no profile found for id")
+}
+
+func (server *Server) getConfigWithProfile() (string, error) {
+ if !server.FSM.HasTransition(HAS_CONFIG) {
+ return "", errors.New("cannot get a config with a profile, invalid state")
+ }
+ profile, profileErr := server.getCurrentProfile()
+
+ if profileErr != nil {
+ return "", profileErr
+ }
+
+ if profile.supportsWireguard() {
+ return server.WireguardGetConfig()
+ }
+ return server.OpenVPNGetConfig()
+}
+
+func (server *Server) askForProfileID() error {
+ if !server.FSM.HasTransition(ASK_PROFILE) {
+ return errors.New("cannot ask for a profile id, invalid state")
+ }
+ server.FSM.GoTransitionWithData(ASK_PROFILE, server.ProfilesRaw)
+ return nil
+}
+
+func (server *Server) GetConfig() (string, error) {
+ if !server.FSM.InState(REQUEST_CONFIG) {
+ return "", errors.New(fmt.Sprintf("cannot get a config, invalid state %s", server.FSM.Current.String()))
+ }
+ infoErr := server.APIInfo()
+
+ if infoErr != nil {
+ return "", infoErr
+ }
+
+ // Set the current profile if there is only one profile
+ if len(server.Profiles.Info.ProfileList) == 1 {
+ server.Profiles.Current = server.Profiles.Info.ProfileList[0].ID
+ return server.getConfigWithProfile()
+ }
+
+ profileErr := server.askForProfileID()
+
+ if profileErr != nil {
+ return "", nil
+ }
+
+ return server.getConfigWithProfile()
+}
diff --git a/internal/test_data/empty b/internal/test_data/empty
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/internal/test_data/empty
diff --git a/internal/test_data/generate.sh b/internal/test_data/generate.sh
new file mode 100644
index 0000000..b1b4545
--- /dev/null
+++ b/internal/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/test_data/generate_forged.py b/internal/test_data/generate_forged.py
new file mode 100644
index 0000000..843b32d
--- /dev/null
+++ b/internal/test_data/generate_forged.py
@@ -0,0 +1,37 @@
+#!/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
new file mode 100644
index 0000000..8c53044
--- /dev/null
+++ b/internal/test_data/organization_list.json
@@ -0,0 +1 @@
+{"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
new file mode 100644
index 0000000..1fa546e
--- /dev/null
+++ b/internal/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/test_data/organization_list.json.tc_servlist.minisig b/internal/test_data/organization_list.json.tc_servlist.minisig
new file mode 100644
index 0000000..a7fe41f
--- /dev/null
+++ b/internal/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/test_data/other_list.json b/internal/test_data/other_list.json
new file mode 100644
index 0000000..25ba1a8
--- /dev/null
+++ b/internal/test_data/other_list.json
@@ -0,0 +1 @@
+{"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
new file mode 100644
index 0000000..eaa2248
--- /dev/null
+++ b/internal/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/test_data/public.key b/internal/test_data/public.key
new file mode 100644
index 0000000..72676d3
--- /dev/null
+++ b/internal/test_data/public.key
@@ -0,0 +1,2 @@
+untrusted comment: minisign public key DF07F868DFAB9B4C
+RWRMm6vfaPgH39iT++NBiUKZim2nDWnalgkNROovPbZdSwVFgUdKU4ac
diff --git a/internal/test_data/random.txt b/internal/test_data/random.txt
new file mode 100644
index 0000000..b6fc4c6
--- /dev/null
+++ b/internal/test_data/random.txt
@@ -0,0 +1 @@
+hello \ No newline at end of file
diff --git a/internal/test_data/secret.key b/internal/test_data/secret.key
new file mode 100644
index 0000000..6e4af37
--- /dev/null
+++ b/internal/test_data/secret.key
@@ -0,0 +1,2 @@
+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
new file mode 100644
index 0000000..67c4c8d
--- /dev/null
+++ b/internal/test_data/server_list.json
@@ -0,0 +1,3 @@
+{
+"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
new file mode 100644
index 0000000..5d2ca5a
--- /dev/null
+++ b/internal/test_data/server_list.json.blake2b
Binary files differ
diff --git a/internal/test_data/server_list.json.forged_keyid.minisig b/internal/test_data/server_list.json.forged_keyid.minisig
new file mode 100644
index 0000000..efa349d
--- /dev/null
+++ b/internal/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/test_data/server_list.json.forged_pure.minisig b/internal/test_data/server_list.json.forged_pure.minisig
new file mode 100644
index 0000000..a362504
--- /dev/null
+++ b/internal/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/test_data/server_list.json.large_time.minisig b/internal/test_data/server_list.json.large_time.minisig
new file mode 100644
index 0000000..79a2a52
--- /dev/null
+++ b/internal/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/test_data/server_list.json.minisig b/internal/test_data/server_list.json.minisig
new file mode 100644
index 0000000..143585b
--- /dev/null
+++ b/internal/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/test_data/server_list.json.pure.minisig b/internal/test_data/server_list.json.pure.minisig
new file mode 100644
index 0000000..57dccfc
--- /dev/null
+++ b/internal/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/test_data/server_list.json.tc_earliertime.minisig b/internal/test_data/server_list.json.tc_earliertime.minisig
new file mode 100644
index 0000000..03da710
--- /dev/null
+++ b/internal/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/test_data/server_list.json.tc_emptyfile.minisig b/internal/test_data/server_list.json.tc_emptyfile.minisig
new file mode 100644
index 0000000..a7aa3ed
--- /dev/null
+++ b/internal/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/test_data/server_list.json.tc_emptytime.minisig b/internal/test_data/server_list.json.tc_emptytime.minisig
new file mode 100644
index 0000000..d3ef01e
--- /dev/null
+++ b/internal/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/test_data/server_list.json.tc_latertime.minisig b/internal/test_data/server_list.json.tc_latertime.minisig
new file mode 100644
index 0000000..8237123
--- /dev/null
+++ b/internal/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/test_data/server_list.json.tc_nofile.minisig b/internal/test_data/server_list.json.tc_nofile.minisig
new file mode 100644
index 0000000..3c1dcbe
--- /dev/null
+++ b/internal/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/test_data/server_list.json.tc_nohashed.minisig b/internal/test_data/server_list.json.tc_nohashed.minisig
new file mode 100644
index 0000000..1d140c1
--- /dev/null
+++ b/internal/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/test_data/server_list.json.tc_notime.minisig b/internal/test_data/server_list.json.tc_notime.minisig
new file mode 100644
index 0000000..39625c3
--- /dev/null
+++ b/internal/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/test_data/server_list.json.tc_orglist.minisig b/internal/test_data/server_list.json.tc_orglist.minisig
new file mode 100644
index 0000000..7c2a3a8
--- /dev/null
+++ b/internal/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/test_data/server_list.json.tc_otherfile.minisig b/internal/test_data/server_list.json.tc_otherfile.minisig
new file mode 100644
index 0000000..58a29b2
--- /dev/null
+++ b/internal/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/test_data/server_list.json.tc_random.minisig b/internal/test_data/server_list.json.tc_random.minisig
new file mode 100644
index 0000000..7240980
--- /dev/null
+++ b/internal/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/test_data/server_list.json.wrong_key.minisig b/internal/test_data/server_list.json.wrong_key.minisig
new file mode 100644
index 0000000..5a83c0e
--- /dev/null
+++ b/internal/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/test_data/wrong_public.key b/internal/test_data/wrong_public.key
new file mode 100644
index 0000000..aa794d4
--- /dev/null
+++ b/internal/test_data/wrong_public.key
@@ -0,0 +1,2 @@
+untrusted comment: minisign public key 802E642EF31BCD0
+RWTQvDHvQuYCCPDLi3UCXzj3BbzFM5QxUFfrp174iaqYo8lT0VaAkhOt
diff --git a/internal/test_data/wrong_secret.key b/internal/test_data/wrong_secret.key
new file mode 100644
index 0000000..68e9092
--- /dev/null
+++ b/internal/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/util.go b/internal/util.go
new file mode 100644
index 0000000..02855c2
--- /dev/null
+++ b/internal/util.go
@@ -0,0 +1,30 @@
+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/verify.go b/internal/verify.go
new file mode 100644
index 0000000..9128777
--- /dev/null
+++ b/internal/verify.go
@@ -0,0 +1,205 @@
+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
+}
+
+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, 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)
+}
+
+// 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:<timestamp>\tfile:<expectedFileName>", 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}
+}
diff --git a/internal/verify_test.go b/internal/verify_test.go
new file mode 100644
index 0000000..f980dc2
--- /dev/null
+++ b/internal/verify_test.go
@@ -0,0 +1,140 @@
+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
new file mode 100644
index 0000000..4ec12bd
--- /dev/null
+++ b/internal/wireguard.go
@@ -0,0 +1,52 @@
+package internal
+
+import (
+ "fmt"
+ "regexp"
+
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+func wireguardGenerateKey() (wgtypes.Key, error) {
+ key, error := wgtypes.GeneratePrivateKey()
+ return key, error
+}
+
+// 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 (server *Server) WireguardGetConfig() (string, error) {
+ profile_id := server.Profiles.Current
+ wireguardKey, wireguardErr := wireguardGenerateKey()
+
+ if wireguardErr != nil {
+ return "", wireguardErr
+ }
+
+ wireguardPublicKey := wireguardKey.PublicKey().String()
+ configWireguard, _, configErr := server.APIConnectWireguard(profile_id, wireguardPublicKey)
+
+ if configErr != nil {
+ return "", configErr
+ }
+
+ // FIXME: Store expiry
+ // 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
+
+ configWireguardKey := wireguardConfigAddKey(configWireguard, wireguardKey)
+
+ return configWireguardKey, nil
+}