diff --git a/providers/dns/nearlyfreespeech/internal/client.go b/providers/dns/nearlyfreespeech/internal/client.go index 1242c6ad..9568c77c 100644 --- a/providers/dns/nearlyfreespeech/internal/client.go +++ b/providers/dns/nearlyfreespeech/internal/client.go @@ -28,6 +28,8 @@ type Client struct { login string apiKey string + signer *Signer + baseURL *url.URL HTTPClient *http.Client } @@ -38,6 +40,7 @@ func NewClient(login string, apiKey string) *Client { return &Client{ login: login, apiKey: apiKey, + signer: NewSigner(), baseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, } @@ -74,7 +77,7 @@ func (c Client) doRequest(ctx context.Context, endpoint *url.URL, params url.Val } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set(authenticationHeader, c.createSignature(endpoint.Path, payload)) + req.Header.Set(authenticationHeader, c.signer.Sign(endpoint.Path, payload, c.login, c.apiKey)) resp, err := c.HTTPClient.Do(req) if err != nil { @@ -90,25 +93,6 @@ func (c Client) doRequest(ctx context.Context, endpoint *url.URL, params url.Val return nil } -func (c Client) createSignature(uri string, body string) string { - // This is the only part of this that needs to be serialized. - salt := make([]byte, 16) - for i := 0; i < 16; i++ { - salt[i] = saltBytes[rand.Intn(len(saltBytes))] - } - - // Header is "login;timestamp;salt;hash". - // hash is SHA1("login;timestamp;salt;api-key;request-uri;body-hash") - // and body-hash is SHA1(body). - - bodyHash := sha1.Sum([]byte(body)) - timestamp := strconv.FormatInt(time.Now().Unix(), 10) - - hashInput := fmt.Sprintf("%s;%s;%s;%s;%s;%02x", c.login, timestamp, salt, c.apiKey, uri, bodyHash) - - return fmt.Sprintf("%s;%s;%s;%02x", c.login, timestamp, salt, sha1.Sum([]byte(hashInput))) -} - func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) @@ -120,3 +104,38 @@ func parseError(req *http.Request, resp *http.Response) error { return errAPI } + +type Signer struct { + saltShaker func() []byte + clock func() time.Time +} + +func NewSigner() *Signer { + return &Signer{saltShaker: getRandomSalt, clock: time.Now} +} + +func (c Signer) Sign(uri string, body, login, apiKey string) string { + // Header is "login;timestamp;salt;hash". + // hash is SHA1("login;timestamp;salt;api-key;request-uri;body-hash") + // and body-hash is SHA1(body). + + bodyHash := sha1.Sum([]byte(body)) + timestamp := strconv.FormatInt(c.clock().Unix(), 10) + + // Workaround for https://golang.org/issue/58605 + uri = "/" + strings.TrimLeft(uri, "/") + + hashInput := fmt.Sprintf("%s;%s;%s;%s;%s;%02x", login, timestamp, c.saltShaker(), apiKey, uri, bodyHash) + + return fmt.Sprintf("%s;%s;%s;%02x", login, timestamp, c.saltShaker(), sha1.Sum([]byte(hashInput))) +} + +func getRandomSalt() []byte { + // This is the only part of this that needs to be serialized. + salt := make([]byte, 16) + for i := 0; i < 16; i++ { + salt[i] = saltBytes[rand.Intn(len(saltBytes))] + } + + return salt +} diff --git a/providers/dns/nearlyfreespeech/internal/client_test.go b/providers/dns/nearlyfreespeech/internal/client_test.go index 05d7d676..16fa82e8 100644 --- a/providers/dns/nearlyfreespeech/internal/client_test.go +++ b/providers/dns/nearlyfreespeech/internal/client_test.go @@ -9,7 +9,9 @@ import ( "net/url" "os" "testing" + "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,6 +26,9 @@ func setupTest(t *testing.T) (*Client, *http.ServeMux) { client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) + client.signer.saltShaker = func() []byte { return []byte("0123456789ABCDEF") } + client.signer.clock = func() time.Time { return time.Unix(1692475113, 0) } + return client, mux } @@ -147,3 +152,63 @@ func TestClient_RemoveRecord_error(t *testing.T) { err := client.RemoveRecord(context.Background(), "example.com", record) require.Error(t, err) } + +func TestSigner_Sign(t *testing.T) { + testCases := []struct { + desc string + path string + now int64 + salt string + expected string + }{ + { + desc: "basic", + path: "/path", + now: 1692475113, + salt: "0123456789ABCDEF", + expected: "user;1692475113;0123456789ABCDEF;417a9988c7ad7919b297884dd120b5808d8a1e6f", + }, + { + desc: "another date", + path: "/path", + now: 1692567766, + salt: "0123456789ABCDEF", + expected: "user;1692567766;0123456789ABCDEF;b5c28286fd2e1a45a7c576dc2a6430116f721502", + }, + { + desc: "another salt", + path: "/path", + now: 1692475113, + salt: "FEDCBA9876543210", + expected: "user;1692475113;FEDCBA9876543210;0f766822bda4fdc09829be4e1ea5e27ae3ae334e", + }, + { + desc: "empty path", + path: "", + now: 1692475113, + salt: "0123456789ABCDEF", + expected: "user;1692475113;0123456789ABCDEF;c7c241a4d15d04d92805631d58d4d72ac1c339a1", + }, + { + desc: "root path", + path: "/", + now: 1692475113, + salt: "0123456789ABCDEF", + expected: "user;1692475113;0123456789ABCDEF;c7c241a4d15d04d92805631d58d4d72ac1c339a1", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + signer := NewSigner() + signer.saltShaker = func() []byte { return []byte(test.salt) } + signer.clock = func() time.Time { return time.Unix(test.now, 0) } + + sign := signer.Sign(test.path, "data", "user", "secret") + + assert.Equal(t, test.expected, sign) + }) + } +}