~shabbyrobe/apiauth

6962f335d437badbafd9310ba5d2447953c3c251 — Blake Williams 3 months ago 205cf9a
Move stuff from main.go into module
5 files changed, 246 insertions(+), 42 deletions(-)

A canonical.go
M cmd/apiauth/main.go
A date.go
A sign.go
A signature.go
A canonical.go => canonical.go +49 -0
@@ 0,0 1,49 @@
package apiauth

import (
	"net/url"
	"strings"
	"time"
)

type CanonicalString string

func CanonicalStringFromRawValues(
	method string,
	contentType string,
	contentHash string,
	uriPath string,
	date string,
) CanonicalString {
	var sb strings.Builder
	sb.WriteString(method)
	sb.WriteByte(',')
	sb.WriteString(contentType)
	sb.WriteByte(',')
	sb.WriteString(contentHash)
	sb.WriteByte(',')
	sb.WriteString(uriPath)
	sb.WriteByte(',')
	sb.WriteString(date)
	return CanonicalString(sb.String())
}

func CanonicalURL(u *url.URL) string {
	uriPath := u.EscapedPath()
	if uriPath == "" {
		uriPath = "/"
	}
	if u.RawQuery != "" {
		uriPath = uriPath + "?" + u.RawQuery
	}
	return uriPath
}

func CanonicalDate(tm time.Time, forcedGMT *time.Location) string {
	if forcedGMT != nil {
		tm = tm.In(forcedGMT)
	} else {
		tm = tm.In(gmt)
	}
	return tm.Format(time.RFC1123)
}

M cmd/apiauth/main.go => cmd/apiauth/main.go +21 -42
@@ 2,19 2,15 @@ package main

import (
	"bufio"
	"crypto/hmac"
	"crypto/sha1"
	"encoding/base64"
	"fmt"
	"log"
	"net/textproto"
	"net/url"
	"os"
	"os/exec"
	"strings"
	"time"

	_ "github.com/davecgh/go-spew/spew"
	"go.shabbyrobe.org/apiauth"
)

func main() {


@@ 90,11 86,6 @@ func (args *Args) consumeFlagValue(short, long string, keep bool) (k, v string, 
}

func run() error {
	gmt, err := time.LoadLocation("Etc/GMT")
	if err != nil {
		return err
	}

	inArgs := NewArgs(os.Args[1:])

	var contentType string


@@ 103,8 94,8 @@ func run() error {
	var date string
	var dateFound bool
	var contentHash string
	var accessID string
	var accessKey string

	var creds apiauth.Credentials

	for inArgs.next() {
		if _, v, ok := inArgs.consumeFlagValue("-H", "--header", true); ok {


@@ 122,10 113,10 @@ func run() error {
			method = strings.ToUpper(v)

		} else if _, v, ok := inArgs.consumeFlagValue("", "--access-id", false); ok {
			accessID = v
			creds.AccessID = v

		} else if _, v, ok := inArgs.consumeFlagValue("", "--access-key", false); ok {
			accessKey = v
			creds.Secret = v

		} else if _, v, ok := inArgs.consumeFlagValue("", "--url", true); ok {
			if rawURL != "" {


@@ 144,42 135,32 @@ func run() error {
	if method == "" {
		method = "GET"
	}
	if accessID == "" {
	if creds.AccessID == "" {
		return fmt.Errorf("--access-id required")
	}
	if accessKey == "" {
	if creds.Secret == "" {
		return fmt.Errorf("--access-key required")
	}

	u, err := url.Parse(rawURL)
	if err != nil {
		return err
	}
	date, _ = apiauth.CoalesceRawDate(date)

	uriPath := u.EscapedPath()
	if uriPath == "" {
		uriPath = "/"
	}
	if u.RawQuery != "" {
		uriPath = uriPath + "?" + u.RawQuery
	}
	signer := apiauth.Signer{Creds: creds}

	if date == "" {
		// Ruby's Time.httpdate function rejects 'UTC':
		date = time.Now().In(gmt).Format(time.RFC1123)
	}

	canonical := strings.Join([]string{
	signInput, err := apiauth.SignInputFromRawValues(
		method,
		contentType,
		contentHash,
		uriPath,
		rawURL,
		date,
	}, ",")
	)
	if err != nil {
		return err
	}

	mac := hmac.New(sha1.New, []byte(accessKey))
	mac.Write([]byte(canonical))
	signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	headerValue, err := signer.AuthHeaderValue(signInput)
	if err != nil {
		return err
	}

	debugCmd := "curl"
	var curlArgs []string


@@ 193,10 174,8 @@ func run() error {
		curlArgs = append(curlArgs, "-H", "Date: "+date)
	}

	debugCmd += fmt.Sprintf(" -H %q",
		fmt.Sprintf("Authorization: APIAuth %s:%s", accessID, signature))

	curlArgs = append(curlArgs, "-H", fmt.Sprintf("Authorization: APIAuth %s:%s", accessID, signature))
	debugCmd += fmt.Sprintf(" -H %q", fmt.Sprintf("Authorization: %s", headerValue))
	curlArgs = append(curlArgs, "-H", fmt.Sprintf("Authorization: %s", headerValue))

	cmd := exec.Command("curl", curlArgs...)
	cmd.Stdout = os.Stdout

A date.go => date.go +26 -0
@@ 0,0 1,26 @@
package apiauth

import "time"

var gmt = func() *time.Location {
	gmt, err := time.LoadLocation("Etc/GMT")
	if err != nil {
		panic(err)
	}
	return gmt
}()

func FormattedNow() string {
	return FormatDate(time.Now())
}

func FormatDate(dt time.Time) string {
	return dt.In(gmt).Format(time.RFC1123)
}

func CoalesceRawDate(rawDate string) (out string, coalesced bool) {
	if rawDate == "" {
		return FormattedNow(), true
	}
	return rawDate, false
}

A sign.go => sign.go +141 -0
@@ 0,0 1,141 @@
package apiauth

import (
	"crypto/hmac"
	"crypto/sha1"
	"encoding/base64"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"time"
)

type Credentials struct {
	AccessID string
	Secret   string
}

const AuthContentHeader = "x-authorization-content-sha256"

type Signer struct {
	Creds Credentials
}

func (s Signer) AuthHeaderValue(input SignInput) (string, error) {
	sig, err := input.Signature(s.Creds)
	if err != nil {
		return "", err
	}

	header := sig.AuthHeaderValue(s.Creds)
	return header, nil
}

func (s Signer) Sign(rq *http.Request) error {
	if dt, coalesced := CoalesceRawDate(rq.Header.Get("Date")); coalesced {
		rq.Header.Set("Date", dt)
	}

	input, err := SignInputFromRequest(rq)
	if err != nil {
		return err
	}

	header, err := s.AuthHeaderValue(input)
	if err != nil {
		return err
	}

	rq.Header.Set("Authorization", header)

	return nil

}

type SignInput struct {
	Method      string
	ContentType string
	ContentHash string
	URL         *url.URL
	Date        time.Time
}

func SignInputFromRawValues(
	method string,
	contentType string,
	contentHash string,
	rawUrl string,
	date string,
) (SignInput, error) {
	dt, err := time.Parse(time.RFC1123, date)
	if err != nil {
		return SignInput{}, fmt.Errorf("apiauth: date %q is not in RFC1123 format: %w", date, err)
	}

	u, err := url.Parse(rawUrl)
	if err != nil {
		return SignInput{}, err
	}

	return SignInput{
		Method:      method,
		ContentType: contentType,
		ContentHash: contentHash,
		URL:         u,
		Date:        dt,
	}, nil
}

func SignInputFromRequest(rq *http.Request) (SignInput, error) {
	si := SignInput{
		Method:      rq.Method,
		ContentType: rq.Header.Get("Content-Type"),
		ContentHash: rq.Header.Get(AuthContentHeader),
		URL:         rq.URL,
	}

	rawDate := rq.Header.Get("Date")
	if rawDate == "" {
		return SignInput{}, fmt.Errorf("apiauth: Date header must be present in request")
	}

	dt, err := time.Parse(time.RFC1123, rawDate)
	if err != nil {
		return SignInput{}, fmt.Errorf("apiauth: date %q is not in RFC1123 format: %w", rawDate, err)
	}
	si.Date = dt

	return si, nil
}

func (si SignInput) Validate() (rerr error) {
	if si.URL == nil {
		rerr = errors.Join(rerr, fmt.Errorf("apiauth: URL is required"))
	}
	if si.Date.IsZero() {
		rerr = errors.Join(rerr, fmt.Errorf("apiauth: Date is required"))
	}
	if si.Method == "" {
		rerr = errors.Join(rerr, fmt.Errorf("apiauth: Method is required"))
	}
	return rerr
}

func (si SignInput) CanonicalString() CanonicalString {
	return CanonicalStringFromRawValues(
		si.Method,
		si.ContentType,
		si.ContentHash,
		CanonicalURL(si.URL),
		FormatDate(si.Date),
	)
}

func (si SignInput) Signature(creds Credentials) (Signature, error) {
	canonical := si.CanonicalString()
	mac := hmac.New(sha1.New, []byte(creds.Secret))
	mac.Write([]byte(canonical))
	signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	return Signature(signature), nil
}

A signature.go => signature.go +9 -0
@@ 0,0 1,9 @@
package apiauth

import "fmt"

type Signature string

func (sig Signature) AuthHeaderValue(creds Credentials) string {
	return fmt.Sprintf("APIAuth %s:%s", creds.AccessID, sig)
}