Skip to content

Commit

Permalink
add httpsig tests
Browse files Browse the repository at this point in the history
  • Loading branch information
dimkr committed Aug 24, 2024
1 parent 97dc9ae commit e98cfb3
Show file tree
Hide file tree
Showing 8 changed files with 1,017 additions and 9 deletions.
3 changes: 2 additions & 1 deletion fed/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"io"
"log/slog"
"net/http"
"time"
)

type sender struct {
Expand All @@ -50,7 +51,7 @@ func (s *sender) send(log *slog.Logger, key httpsig.Key, req *http.Request) (*ht

log.Debug("Sending request", "url", urlString)

if err := httpsig.Sign(req, key); err != nil {
if err := httpsig.Sign(req, key, time.Now()); err != nil {
return nil, fmt.Errorf("failed to sign request for %s: %w", urlString, err)
}

Expand Down
3 changes: 2 additions & 1 deletion fed/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ import (
"github.com/dimkr/tootik/httpsig"
"log/slog"
"net/http"
"time"
)

func verify(ctx context.Context, domain string, cfg *cfg.Config, log *slog.Logger, r *http.Request, body []byte, db *sql.DB, resolver *Resolver, key httpsig.Key, flags ap.ResolverFlag) (*ap.Actor, error) {
sig, err := httpsig.Extract(r, body, domain, cfg.MaxRequestAge)
sig, err := httpsig.Extract(r, body, domain, time.Now(), cfg.MaxRequestAge)
if err != nil {
return nil, fmt.Errorf("failed to verify message: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion httpsig/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ limitations under the License.

package httpsig

// Key is used to sign outgoing HTTP requests.
// Key is used to sign outgoing HTTP requests or verify incoming HTTP requests.
type Key struct {
ID string
PrivateKey any
Expand Down
4 changes: 2 additions & 2 deletions httpsig/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ var (
)

// Sign adds a signature to an outgoing HTTP request.
func Sign(r *http.Request, key Key) error {
func Sign(r *http.Request, key Key, now time.Time) error {
if key.ID == "" {
return errors.New("empty key ID")
}
Expand All @@ -55,7 +55,7 @@ func Sign(r *http.Request, key Key) error {
headers = postHeaders
}

r.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
r.Header.Set("Date", now.UTC().Format(http.TimeFormat))
r.Header.Set("Host", r.URL.Host)

s, err := buildSignatureString(r, headers)
Expand Down
109 changes: 109 additions & 0 deletions httpsig/sign_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
Copyright 2024 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless ruired by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package httpsig

import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"github.com/stretchr/testify/assert"
"net/http"
"testing"
"time"
)

func TestSign_HappyFlow(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
assert.NoError(t, err)

body := []byte(`{"id":"a"}`)
req, err := http.NewRequest(http.MethodPost, "http://localhost/inbox/nobody", bytes.NewReader(body))
assert.NoError(t, err)

req.Header.Set("Accept", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)
req.Header.Set("Content-Type", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)

now := time.Now()
assert.NoError(t, Sign(req, Key{ID: "http://localhost/key/nobody", PrivateKey: priv}, now))

sig, err := Extract(req, body, "localhost", now, time.Minute)
assert.NoError(t, err)

assert.NoError(t, sig.Verify(&priv.PublicKey))
}

func TestSign_Get(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
assert.NoError(t, err)

body := []byte(`{"id":"a"}`)
req, err := http.NewRequest(http.MethodGet, "http://localhost/inbox/nobody", bytes.NewReader(body))
assert.NoError(t, err)

now := time.Now()
assert.NoError(t, Sign(req, Key{ID: "http://localhost/key/nobody", PrivateKey: priv}, now))

sig, err := Extract(req, nil, "localhost", now, time.Minute)
assert.NoError(t, err)

assert.NoError(t, sig.Verify(&priv.PublicKey))
}

func TestSign_NoKeyID(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
assert.NoError(t, err)

body := []byte(`{"id":"a"}`)
req, err := http.NewRequest(http.MethodPost, "http://localhost/inbox/nobody", bytes.NewReader(body))
assert.NoError(t, err)

req.Header.Set("Accept", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)
req.Header.Set("Content-Type", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)

now := time.Now()
assert.Error(t, Sign(req, Key{PrivateKey: priv}, now))
}

func TestSign_WrongKeyType(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
assert.NoError(t, err)

body := []byte(`{"id":"a"}`)
req, err := http.NewRequest(http.MethodPost, "http://localhost/inbox/nobody", bytes.NewReader(body))
assert.NoError(t, err)

req.Header.Set("Accept", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)
req.Header.Set("Content-Type", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)

now := time.Now()
assert.Error(t, Sign(req, Key{ID: "http://localhost/key/nobody", PrivateKey: priv}, now))
}

func TestSign_MissingHeader(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
assert.NoError(t, err)

body := []byte(`{"id":"a"}`)
req, err := http.NewRequest(http.MethodPost, "http://localhost/inbox/nobody", bytes.NewReader(body))
assert.NoError(t, err)

req.Header.Set("Accept", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)

now := time.Now()
assert.Error(t, Sign(req, Key{ID: "http://localhost/key/nobody", PrivateKey: priv}, now))
}
6 changes: 5 additions & 1 deletion httpsig/string.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package httpsig
import (
"fmt"
"net/http"
"net/textproto"
"strings"
)

Expand All @@ -42,7 +43,10 @@ func buildSignatureString(r *http.Request, headers []string) (string, error) {
b.WriteString(strings.ToLower(h))
b.WriteByte(':')
b.WriteByte(' ')
values := r.Header.Values(h)
values, ok := r.Header[textproto.CanonicalMIMEHeaderKey(h)]
if !ok || len(values) == 0 {
return "", fmt.Errorf("unspecified header: " + h)
}
for j, v := range values {
b.WriteString(strings.TrimSpace(v))
if j < len(values)-1 {
Expand Down
9 changes: 6 additions & 3 deletions httpsig/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ const (

var signatureAttrRegex = regexp.MustCompile(`\b([^"=]+)="([^"]+)"`)

// Extract extracts signature attributes and returns a [Signature].
// Extract extracts signature attributes, validates them and returns a [Signature].
// Caller should obtain the key and pass it to [Signature.Verify].
func Extract(r *http.Request, body []byte, domain string, maxAge time.Duration) (*Signature, error) {
func Extract(r *http.Request, body []byte, domain string, now time.Time, maxAge time.Duration) (*Signature, error) {
host := r.Header.Get("Host")
if host == "" {
if r.Host == "" {
Expand All @@ -71,9 +71,12 @@ func Extract(r *http.Request, body []byte, domain string, maxAge time.Duration)
return nil, err
}

if time.Since(t) > maxAge {
if now.Sub(t) > maxAge {
return nil, errors.New("date is too old")
}
if t.Sub(now) > maxAge {
return nil, errors.New("date is too new")
}

values := r.Header.Values("Signature")
if len(values) > 1 {
Expand Down
Loading

0 comments on commit e98cfb3

Please sign in to comment.