diff --git a/canonicalize.go b/canonicalize.go index 2d9df8b..5559045 100644 --- a/canonicalize.go +++ b/canonicalize.go @@ -15,26 +15,49 @@ import ( "time" ) -// message is a minimal representation of an HTTP request or response, containing the values +// Message is a minimal representation of an HTTP request or response, containing the values // needed to construct a signature. -type message struct { +type Message struct { Method string Authority string URL *nurl.URL Header http.Header } -func messageFromRequest(r *http.Request) *message { +var DefaultPorts = map[string]struct{}{ + "80": {}, // http + "443": {}, // https + "21": {}, // ftp +} + +func MessageFromRequest(r *http.Request) *Message { hdr := r.Header.Clone() hdr.Set("Host", r.Host) - return &message{ - Method: r.Method, - Authority: r.Host, + return &Message{ + Method: r.Method, + // TODO Host header is used only in HTTP/1.1 - for other versions we should be using :authority header + // need to remove default port from Host header: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-19#content-request-authority + Authority: normalizeHostHeader(r.Host), URL: r.URL, Header: hdr, } } +func normalizeHostHeader(host string) string { + hostParts := strings.Split(host, ":") + if len(hostParts) == 0 || len(hostParts) > 2 { + panic("invalid host header: " + host) + } else if len(hostParts) == 1 { + return host + } else { + if _, isFound := DefaultPorts[hostParts[1]]; isFound { + return hostParts[0] + } else { + return host + } + } +} + func canonicalizeHeader(out io.Writer, name string, hdr http.Header) error { // XXX: Structured headers are not considered, and they should be :) v := hdr.Values(name) @@ -73,6 +96,13 @@ func canonicalizePath(out io.Writer, path string) error { return err } +func canonicalizeRequestTarget(out io.Writer, requestTarget string) error { + // Section 2.2.5 (v19) covers canonicalization of the path. + // Section 2.4 step 2 covers using it as input. + _, err := fmt.Fprintf(out, "\"@request-target\": %s\n", requestTarget) + return err +} + func canonicalizeQuery(out io.Writer, rawQuery string) error { // Section 2.3.8 covers canonicalization of the query. // Section 2.4 step 2 covers using it as input. @@ -94,12 +124,13 @@ func canonicalizeSignatureParams(out io.Writer, sp *signatureParams) error { } type signatureParams struct { - items []string - keyID string - alg string - created time.Time - expires *time.Time - nonce string + items []string + paramsOrder []string + keyID string + alg string + created time.Time + expires *time.Time + nonce string } func (sp *signatureParams) canonicalize() string { @@ -111,19 +142,19 @@ func (sp *signatureParams) canonicalize() string { // Items comes first. The params afterwards can be in any order. The order chosen here // matches what's in the examples in the standard, aiding in testing. - - o += fmt.Sprintf(";created=%d", sp.created.Unix()) - - if sp.keyID != "" { - o += fmt.Sprintf(";keyid=\"%s\"", sp.keyID) - } - - if sp.alg != "" { - o += fmt.Sprintf(";alg=\"%s\"", sp.alg) - } - - if sp.expires != nil { - o += fmt.Sprintf(";expires=%d", sp.expires.Unix()) + for _, param := range sp.paramsOrder { + switch param { + case "created": + o += fmt.Sprintf(";created=%d", sp.created.Unix()) + case "expires": + o += fmt.Sprintf(";expires=%d", sp.expires.Unix()) + case "keyid": + o += fmt.Sprintf(";keyid=\"%s\"", sp.keyID) + case "alg": + o += fmt.Sprintf(";alg=\"%s\"", sp.alg) + case "nonce": + o += fmt.Sprintf(";nonce=\"%s\"", sp.nonce) + } } return o @@ -156,10 +187,10 @@ func parseSignatureInput(in string) (*signatureParams, error) { } for _, param := range parts[1:] { - paramParts := strings.Split(param, "=") - if len(paramParts) != 2 { - return nil, errMalformedSignatureInput - } + // keyid can be base64 encoded, so it can have = symbols at the end + paramParts := strings.SplitN(param, "=", 2) + + sp.paramsOrder = append(sp.paramsOrder, paramParts[0]) // TODO: error when not wrapped in quotes switch paramParts[0] { diff --git a/example_test.go b/example_test.go index 2dd62eb..08111a5 100644 --- a/example_test.go +++ b/example_test.go @@ -10,7 +10,7 @@ import ( "net/http" "time" - "github.com/jbowes/httpsig" + "github.com/ynodir/httpsig" ) const secret = "support-your-local-cat-bonnet-store" diff --git a/go.mod b/go.mod index 759e3a1..4056817 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/jbowes/httpsig +module github.com/ynodir/httpsig go 1.18 diff --git a/httpsig.go b/httpsig.go index 7cb53ec..4236509 100644 --- a/httpsig.go +++ b/httpsig.go @@ -32,9 +32,9 @@ func sliceHas(haystack []string, needle string) bool { // key ids. You must provide at least one signing option. A signature for every provided key id is // included on each request. Multiple included signatures allow you to gracefully introduce stronger // algorithms, rotate keys, etc. -func NewSignTransport(transport http.RoundTripper, opts ...signOption) http.RoundTripper { - s := signer{ - keys: map[string]sigHolder{}, +func NewSignTransport(transport http.RoundTripper, opts ...SignOption) http.RoundTripper { + s := Signer{ + keys: map[string]SigHolder{}, nowFunc: time.Now, } @@ -76,7 +76,7 @@ func NewSignTransport(transport http.RoundTripper, opts ...signOption) http.Roun // TODO: we could skip setting digest on an empty body if content-length is included in the sig nr.Header.Set("Digest", calcDigest(b.Bytes())) - msg := messageFromRequest(nr) + msg := MessageFromRequest(nr) hdr, err := s.Sign(msg) if err != nil { return nil, err @@ -103,7 +103,7 @@ func (r rt) RoundTrip(req *http.Request) (*http.Response, error) { return r(req) // Requests with missing signatures, malformed signature headers, expired signatures, or // invalid signatures are rejected with a `400` response. Only one valid signature is required // from the known key ids. However, only the first known key id is checked. -func NewVerifyMiddleware(opts ...verifyOption) func(http.Handler) http.Handler { +func NewVerifyMiddleware(opts ...VerifyOption) func(http.Handler) http.Handler { // TODO: form and multipart support v := verifier{ @@ -118,7 +118,7 @@ func NewVerifyMiddleware(opts ...verifyOption) func(http.Handler) http.Handler { serveErr := func(rw http.ResponseWriter) { // TODO: better error and custom error handler rw.Header().Set("Content-Type", "text/plain") - rw.WriteHeader(http.StatusBadRequest) + rw.WriteHeader(http.StatusUnauthorized) _, _ = rw.Write([]byte("invalid required signature")) } @@ -126,7 +126,7 @@ func NewVerifyMiddleware(opts ...verifyOption) func(http.Handler) http.Handler { return func(h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - msg := messageFromRequest(r) + msg := MessageFromRequest(r) err := v.Verify(msg) if err != nil { serveErr(rw) @@ -161,49 +161,49 @@ func NewVerifyMiddleware(opts ...verifyOption) func(http.Handler) http.Handler { } } -type signOption interface { - configureSign(s *signer) +type SignOption interface { + configureSign(s *Signer) } -type verifyOption interface { +type VerifyOption interface { configureVerify(v *verifier) } -type signOrVerifyOption interface { - signOption - verifyOption +type SignOrVerifyOption interface { + SignOption + VerifyOption } type optImpl struct { - s func(s *signer) + s func(s *Signer) v func(v *verifier) } -func (o *optImpl) configureSign(s *signer) { o.s(s) } +func (o *optImpl) configureSign(s *Signer) { o.s(s) } func (o *optImpl) configureVerify(v *verifier) { o.v(v) } // WithHeaders sets the list of headers that will be included in the signature. // The Digest header is always included (and the digest calculated). // // If not provided, the default headers `content-type, content-length, host` are used. -func WithHeaders(hdr ...string) signOption { +func WithHeaders(hdr ...string) SignOption { // TODO: use this to implement required headers in verify? return &optImpl{ - s: func(s *signer) { s.headers = hdr }, + s: func(s *Signer) { s.headers = hdr }, } } // WithSignRsaPssSha512 adds signing using `rsa-pss-sha512` with the given private key // using the given key id. -func WithSignRsaPssSha512(keyID string, pk *rsa.PrivateKey) signOption { +func WithSignRsaPssSha512(keyID string, pk *rsa.PrivateKey) SignOption { return &optImpl{ - s: func(s *signer) { s.keys[keyID] = signRsaPssSha512(pk) }, + s: func(s *Signer) { s.keys[keyID] = SignRsaPssSha512(pk) }, } } // WithVerifyRsaPssSha512 adds signature verification using `rsa-pss-sha512` with the // given public key using the given key id. -func WithVerifyRsaPssSha512(keyID string, pk *rsa.PublicKey) verifyOption { +func WithVerifyRsaPssSha512(keyID string, pk *rsa.PublicKey) VerifyOption { return &optImpl{ v: func(v *verifier) { v.keys[keyID] = verifyRsaPssSha512(pk) }, } @@ -211,15 +211,15 @@ func WithVerifyRsaPssSha512(keyID string, pk *rsa.PublicKey) verifyOption { // WithSignEcdsaP256Sha256 adds signing using `ecdsa-p256-sha256` with the given private key // using the given key id. -func WithSignEcdsaP256Sha256(keyID string, pk *ecdsa.PrivateKey) signOption { +func WithSignEcdsaP256Sha256(keyID string, pk *ecdsa.PrivateKey) SignOption { return &optImpl{ - s: func(s *signer) { s.keys[keyID] = signEccP256(pk) }, + s: func(s *Signer) { s.keys[keyID] = SignEccP256(pk) }, } } // WithVerifyEcdsaP256Sha256 adds signature verification using `ecdsa-p256-sha256` with the // given public key using the given key id. -func WithVerifyEcdsaP256Sha256(keyID string, pk *ecdsa.PublicKey) verifyOption { +func WithVerifyEcdsaP256Sha256(keyID string, pk *ecdsa.PublicKey) VerifyOption { return &optImpl{ v: func(v *verifier) { v.keys[keyID] = verifyEccP256(pk) }, } @@ -227,9 +227,9 @@ func WithVerifyEcdsaP256Sha256(keyID string, pk *ecdsa.PublicKey) verifyOption { // WithHmacSha256 adds signing or signature verification using `hmac-sha256` with the // given shared secret using the given key id. -func WithHmacSha256(keyID string, secret []byte) signOrVerifyOption { +func WithHmacSha256(keyID string, secret []byte) SignOrVerifyOption { return &optImpl{ - s: func(s *signer) { s.keys[keyID] = signHmacSha256(secret) }, + s: func(s *Signer) { s.keys[keyID] = SignHmacSha256(secret) }, v: func(v *verifier) { v.keys[keyID] = verifyHmacSha256(secret) }, } } diff --git a/sign.go b/sign.go index 68acd06..b76a51f 100644 --- a/sign.go +++ b/sign.go @@ -25,20 +25,28 @@ type sigImpl struct { sign func() []byte } -type sigHolder struct { +type SigHolder struct { alg string signer func() sigImpl } -type signer struct { +type Signer struct { headers []string - keys map[string]sigHolder + keys map[string]SigHolder // For testing nowFunc func() time.Time } -func (s *signer) Sign(msg *message) (http.Header, error) { +func NewSigner(headers []string, keys map[string]SigHolder) *Signer { + return &Signer{ + headers: headers, + keys: keys, + nowFunc: time.Now, + } +} + +func (s *Signer) Sign(msg *Message) (http.Header, error) { var b bytes.Buffer var items []string @@ -61,6 +69,8 @@ func (s *signer) Sign(msg *message) (http.Header, error) { err = canonicalizeQuery(&b, msg.URL.RawQuery) case "@authority": err = canonicalizeAuthority(&b, msg.Authority) + case "@request-target": + err = canonicalizeRequestTarget(&b, msg.URL.RequestURI()) default: // handle default (header) components err = canonicalizeHeader(&b, h, msg.Header) @@ -80,10 +90,11 @@ func (s *signer) Sign(msg *message) (http.Header, error) { i := 1 // 1 indexed icky for k, si := range s.keys { sp := &signatureParams{ - items: items, - keyID: k, - created: now, - alg: si.alg, + items: items, + keyID: k, + created: now, + alg: si.alg, + paramsOrder: []string{"created", "keyid"}, } sps[fmt.Sprintf("sig%d", i)] = sp.canonicalize() @@ -122,8 +133,8 @@ func (s *signer) Sign(msg *message) (http.Header, error) { return hdr, nil } -func signRsaPssSha512(pk *rsa.PrivateKey) sigHolder { - return sigHolder{ +func SignRsaPssSha512(pk *rsa.PrivateKey) SigHolder { + return SigHolder{ alg: "rsa-pss-sha512", signer: func() sigImpl { h := sha256.New() @@ -142,8 +153,8 @@ func signRsaPssSha512(pk *rsa.PrivateKey) sigHolder { } } -func signEccP256(pk *ecdsa.PrivateKey) sigHolder { - return sigHolder{ +func SignEccP256(pk *ecdsa.PrivateKey) SigHolder { + return SigHolder{ alg: "ecdsa-p256-sha256", signer: func() sigImpl { h := sha256.New() @@ -162,9 +173,9 @@ func signEccP256(pk *ecdsa.PrivateKey) sigHolder { } } -func signHmacSha256(secret []byte) sigHolder { - // TODO: add alg description - return sigHolder{ +func SignHmacSha256(secret []byte) SigHolder { + return SigHolder{ + alg: "hmac-sha256", signer: func() sigImpl { h := hmac.New(sha256.New, secret) diff --git a/standard_test.go b/standard_test.go index 4c85ae4..33f6b39 100644 --- a/standard_test.go +++ b/standard_test.go @@ -27,8 +27,8 @@ func parse(in string) *url.URL { return out } -func testReq() *message { - return &message{ +func testReq() *Message { + return &Message{ Method: "POST", Authority: "example.com", URL: parse("https://example.com/foo?param=value&pet=dog"), @@ -48,10 +48,10 @@ func TestSign_B_2_5(t *testing.T) { panic("could not decode test shared secret") } - s := &signer{ + s := &Signer{ headers: []string{"@authority", "date", "content-type"}, - keys: map[string]sigHolder{ - "test-shared-secret": signHmacSha256(k), + keys: map[string]SigHolder{ + "test-shared-secret": SignHmacSha256(k), }, nowFunc: func() time.Time { return time.Unix(1618884475, 0) }, @@ -221,6 +221,37 @@ func TestVerify_B_2_5(t *testing.T) { } } +func TestVerify_AudioHook(t *testing.T) { + k, err := base64.StdEncoding.DecodeString("TXlTdXBlclNlY3JldEtleVRlbGxOby0xITJAMyM0JDU=") + if err != nil { + panic("could not decode test shared secret") + } + + v := &verifier{ + keys: map[string]verHolder{ + "SGVsbG8sIEkgYW0gdGhlIEFQSSBrZXkh": verifyHmacSha256(k), + }, + + nowFunc: func() time.Time { return time.Unix(1618884475, 0) }, + } + + req := testReq() + req.URL = parse("/api/v1/voicebiometrics/ws") + req.Authority = "audiohook.example.com" + req.Header.Set("Host", "audiohook.example.com") + req.Header.Set("Audiohook-Organization-Id", "d7934305-0972-4844-938e-9060eef73d05") + req.Header.Set("Audiohook-Correlation-Id", "e160e428-53e2-487c-977d-96989bf5c99d") + req.Header.Set("Audiohook-Session-Id", "30b0e395-84d3-4570-ac13-9a62d8f514c0") + req.Header.Set("X-API-KEY", "SGVsbG8sIEkgYW0gdGhlIEFQSSBrZXkh") + req.Header.Set("Signature-Input", `sig1=("@request-target" "@authority" "audiohook-organization-id" "audiohook-session-id" "audiohook-correlation-id" "x-api-key");keyid="SGVsbG8sIEkgYW0gdGhlIEFQSSBrZXkh";nonce="VGhpc0lzQVVuaXF1ZU5vbmNl";alg="hmac-sha256";created=1641013200;expires=3282026430`) + req.Header.Set("Signature", `sig1=:NZBwyBHRRyRoeLqy1IzOa9VYBuI8TgMFt2GRDkDuJh4=:`) + + err = v.Verify(req) + if err != nil { + t.Error("verification failed:", err) + } +} + // The following keypairs are taken from the Draft Standard, so we may recreate the examples in tests. // If your robot scans this repo and says it's leaking keys I will be mildly amused. diff --git a/verify.go b/verify.go index 80a1594..dc9bdd8 100644 --- a/verify.go +++ b/verify.go @@ -37,7 +37,7 @@ type verifier struct { } // XXX: note about fail fast. -func (v *verifier) Verify(msg *message) error { +func (v *verifier) Verify(msg *Message) error { sigHdr := msg.Header.Get("Signature") if sigHdr == "" { return errNotSigned @@ -131,6 +131,8 @@ func (v *verifier) Verify(msg *message) error { err = canonicalizeQuery(&b, msg.URL.RawQuery) case "@authority": err = canonicalizeAuthority(&b, msg.Authority) + case "@request-target": + err = canonicalizeRequestTarget(&b, msg.URL.RequestURI()) default: // handle default (header) components err = canonicalizeHeader(&b, h, msg.Header) @@ -154,8 +156,8 @@ func (v *verifier) Verify(msg *message) error { return errInvalidSignature } - // TODO: could put in some wiggle room - if params.expires != nil && params.expires.After(time.Now()) { + // 1 min of wiggle room for time sync between client and server + if params.expires != nil && params.expires.Before(time.Now().Add(-1*time.Minute)) { return errSignatureExpired }