diff --git a/fed/send.go b/fed/send.go index 0b34bcb5..0ddc2692 100644 --- a/fed/send.go +++ b/fed/send.go @@ -25,6 +25,7 @@ import ( "io" "log/slog" "net/http" + "time" ) type sender struct { @@ -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) } diff --git a/fed/verify.go b/fed/verify.go index c92b1e3b..4ad187fe 100644 --- a/fed/verify.go +++ b/fed/verify.go @@ -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) } diff --git a/httpsig/key.go b/httpsig/key.go index 6bf9db42..859f5b7e 100644 --- a/httpsig/key.go +++ b/httpsig/key.go @@ -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 diff --git a/httpsig/sign.go b/httpsig/sign.go index 687c8ea6..921f4a49 100644 --- a/httpsig/sign.go +++ b/httpsig/sign.go @@ -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") } @@ -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) diff --git a/httpsig/sign_test.go b/httpsig/sign_test.go new file mode 100644 index 00000000..1476ca28 --- /dev/null +++ b/httpsig/sign_test.go @@ -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)) +} diff --git a/httpsig/string.go b/httpsig/string.go index 96f4c1c3..48adba21 100644 --- a/httpsig/string.go +++ b/httpsig/string.go @@ -19,6 +19,7 @@ package httpsig import ( "fmt" "net/http" + "net/textproto" "strings" ) @@ -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 { diff --git a/httpsig/verify.go b/httpsig/verify.go index 4b8c5dfc..7a222056 100644 --- a/httpsig/verify.go +++ b/httpsig/verify.go @@ -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 == "" { @@ -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 { diff --git a/httpsig/verify_test.go b/httpsig/verify_test.go new file mode 100644 index 00000000..c86c880d --- /dev/null +++ b/httpsig/verify_test.go @@ -0,0 +1,890 @@ +/* +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" + "strings" + "testing" + "time" +) + +func TestVerify_TooOld(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.Add(-time.Minute*2))) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_TooNew(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.Add(time.Minute*2))) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_NoDate(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)) + + req.Header.Del("Date") + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_InvalidDate(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)) + + req.Header.Set("Date", "a") + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_WrongHost(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)) + + _, err = Extract(req, body, "wrong", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_EmptyHost(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)) + + req.Header.Del("Host") + req.Host = "" + + _, err = Extract(req, body, "wrong", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_NoHostFallback(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)) + + req.Header.Del("Host") + + sig, err := Extract(req, body, "localhost", now, time.Minute) + assert.NoError(t, err) + + assert.NoError(t, sig.Verify(&priv.PublicKey)) +} + +func TestVerify_NoHostWrongFallback(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)) + + req.Header.Del("Host") + + _, err = Extract(req, body, "wrong", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_TwoSignatureHeaders(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)) + + req.Header.Add("Signature", "") + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_TwoKeyIDs(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)) + + req.Header.Set("Signature", req.Header.Get("Signature")+`,keyId="a"`) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_TwoSignatures(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)) + + req.Header.Set("Signature", req.Header.Get("Signature")+`,signature="a"`) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_TwoHeaders(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)) + + req.Header.Set("Signature", req.Header.Get("Signature")+`,headers="a"`) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_InvalidAttribute(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)) + + req.Header.Set("Signature", req.Header.Get("Signature")+`,a="b"`) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_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.NoError(t, Sign(req, Key{ID: "http://localhost/key/nobody", PrivateKey: priv}, now)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), "keyId", "algorithm", 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_NoSignature(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), "signature", "algorithm", 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_NoHeaders(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), "headers", "algorithm", 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_InvalidSignatureBase64(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), `signature="`, `signature="a`, 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_DuplicateHeaders(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), `(request-target)`, `(request-target) (request-target)`, 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_HeadersOnlyWhitespace(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), `headers="`+strings.Join(postHeaders, " ")+`"`, `headers=" "`, 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_HeadersLeadingWhitespace(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)) + + req.Header.Set("Signature", "\t\t"+req.Header.Get("Signature")) + + sig, err := Extract(req, body, "localhost", now, time.Minute) + assert.NoError(t, err) + + assert.NoError(t, sig.Verify(&priv.PublicKey)) +} + +func TestVerify_HeadersTrailingWhitespace(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)) + + req.Header.Set("Signature", req.Header.Get("Signature")+"\t\t") + + sig, err := Extract(req, body, "localhost", now, time.Minute) + assert.NoError(t, err) + + assert.NoError(t, sig.Verify(&priv.PublicKey)) +} + +func TestVerify_HeadersContainsWhitespace(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), " content-type", " content-type\t\t", 1)) + + sig, err := Extract(req, body, "localhost", now, time.Minute) + assert.NoError(t, err) + + assert.NoError(t, sig.Verify(&priv.PublicKey)) +} + +func TestVerify_TargetNotSigned(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), "(request-target) ", "", 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_HostNotSigned(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), " host ", " ", 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_DateNotSigned(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), " date ", " ", 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_DigestNotSigned(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), ` digest"`, `"`, 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_MissingSignedHeader(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), `(request-target)`, `(request-target) aaa`, 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_MissingSpecialSignedHeader(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)) + + req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), `(request-target)`, `(request-target) (request-aaa)`, 1)) + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_DuplicateSignedHeader(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)) + + req.Header.Add("Date", req.Header.Get("Date")) + + sig, err := Extract(req, body, "localhost", now, time.Minute) + assert.NoError(t, err) + + assert.Error(t, sig.Verify(&priv.PublicKey)) +} + +func TestVerify_NoDigest(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)) + + req.Header.Del("Digest") + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_ShortDigest(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)) + + req.Header.Set("Digest", "a") + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_InvalidDigestAlgorithm(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)) + + req.Header.Set("Digest", "SHA-512=a") + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_InvalidDigestBase64(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)) + + req.Header.Set("Digest", req.Header.Get("Digest")+"a") + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_InvalidDigestHashSize(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)) + + req.Header.Set("Digest", "SHA-256=DMF1ucDxtqgxw5niaXcmYQ==") + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_WrongHash(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)) + + req.Header.Set("Digest", "SHA-256=ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=") + + _, err = Extract(req, body, "localhost", now, time.Minute) + assert.Error(t, err) +} + +func TestVerify_DifferentMethod(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)) + + req.Method = http.MethodGet + + sig, err := Extract(req, body, "localhost", now, time.Minute) + assert.NoError(t, err) + + assert.Error(t, sig.Verify(&priv.PublicKey)) +} + +func TestVerify_DifferentHost(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://invalid/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://invalid/key/nobody", PrivateKey: priv}, now)) + + req.Header.Set("Host", "localhost") + + sig, err := Extract(req, body, "localhost", now, time.Minute) + assert.NoError(t, err) + + assert.Error(t, sig.Verify(&priv.PublicKey)) +} + +func TestVerify_DifferentDate(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)) + + req.Header.Set("Date", now.Add(-time.Second).UTC().Format(http.TimeFormat)) + + sig, err := Extract(req, body, "localhost", now, time.Minute) + assert.NoError(t, err) + + assert.Error(t, sig.Verify(&priv.PublicKey)) +} + +func TestVerify_DifferentContentType(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)) + + req.Header.Set("Content-Type", "text/plain") + + sig, err := Extract(req, body, "localhost", now, time.Minute) + assert.NoError(t, err) + + assert.Error(t, sig.Verify(&priv.PublicKey)) +} + +func TestVerify_WrongKey(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) + + priv2, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + assert.NoError(t, sig.Verify(&priv.PublicKey)) + assert.Error(t, sig.Verify(&priv2.PublicKey)) +} + +func TestVerify_SmallKey(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 1024) + 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.Error(t, sig.Verify(&priv.PublicKey)) +} + +func TestVerify_WrongKeyType(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) + + pub2, _, err := ed25519.GenerateKey(rand.Reader) + assert.NoError(t, err) + + assert.NoError(t, sig.Verify(&priv.PublicKey)) + assert.Error(t, sig.Verify(pub2)) +}