• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

go-ap / client / #3

08 Apr 2026 12:29PM UTC coverage: 28.68% (+7.1%) from 21.588%
#3

push

sourcehut

mariusor
Added some proxy round tripper tests

7 of 14 new or added lines in 1 file covered. (50.0%)

34 existing lines in 1 file now uncovered.

302 of 1053 relevant lines covered (28.68%)

0.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.33
/s2s/httpsignatures.go
1
package s2s
2

3
import (
4
        "bytes"
5
        "context"
6
        "crypto"
7
        "crypto/ecdsa"
8
        "crypto/ed25519"
9
        "crypto/rsa"
10
        "crypto/x509"
11
        "encoding/pem"
12
        "fmt"
13
        "io"
14
        "net/http"
15
        "strings"
16
        "time"
17

18
        "git.sr.ht/~mariusor/lw"
19
        e "github.com/common-fate/httpsig/alg_ecdsa"
20
        ed "github.com/common-fate/httpsig/alg_ed25519"
21
        r "github.com/common-fate/httpsig/alg_rsa"
22
        "github.com/common-fate/httpsig/signer"
23
        vocab "github.com/go-ap/activitypub"
24
        "github.com/go-ap/errors"
25
        "github.com/go-fed/httpsig"
26
)
27

28
var (
29
        nilLogger = lw.Dev(lw.SetOutput(io.Discard))
30

31
        digestAlgorithm     = httpsig.DigestSha256
32
        signatureExpiration = int64(time.Hour.Seconds())
33
)
34

35
type HTTPSignatureTransport struct {
36
        signer.Transport
37

38
        Key   crypto.PrivateKey
39
        Actor *vocab.Actor
40

41
        l lw.Logger
42
}
43

44
type OptionFn func(transport *HTTPSignatureTransport) error
45

46
func WithTransport(tr http.RoundTripper) OptionFn {
1✔
47
        return func(h *HTTPSignatureTransport) error {
2✔
48
                h.Transport.BaseTransport = tr
1✔
49
                return nil
1✔
50
        }
1✔
51
}
52

53
func NoRFC9421(h *HTTPSignatureTransport) error {
1✔
54
        h.Transport.Alg = nil
1✔
55
        return nil
1✔
56
}
1✔
57

58
func WithActor(act *vocab.Actor, prv crypto.PrivateKey) OptionFn {
8✔
59
        return func(h *HTTPSignatureTransport) error {
16✔
60
                h.Actor = act
8✔
61
                h.Key = prv
8✔
62

8✔
63
                if act == nil {
10✔
64
                        return nil
2✔
65
                }
2✔
66

67
                h.Transport.KeyID = string(act.PublicKey.ID)
6✔
68
                actorPubKey, err := toCryptoPublicKey(act.PublicKey)
6✔
69
                if err != nil {
7✔
70
                        return errors.Annotatef(err, "unable to sign request, Actor public key type %T is invalid", actorPubKey)
1✔
71
                }
1✔
72

73
                if prv == nil {
6✔
74
                        return nil
1✔
75
                }
1✔
76
                switch pk := prv.(type) {
4✔
77
                case *rsa.PrivateKey:
2✔
78
                        pub, _ := pk.Public().(*rsa.PublicKey)
2✔
79
                        if !pub.Equal(actorPubKey) {
2✔
80
                                return keyMismatchErr(pk, actorPubKey)
×
81
                        }
×
82
                        h.Transport.Alg = r.NewRSAPKCS256Signer(pk)
2✔
83
                case *ecdsa.PrivateKey:
1✔
84
                        pub, _ := pk.Public().(*ecdsa.PublicKey)
1✔
85
                        if !pub.Equal(actorPubKey) {
1✔
UNCOV
86
                                return keyMismatchErr(pk, actorPubKey)
×
UNCOV
87
                        }
×
88
                        h.Transport.Alg = e.NewP384Signer(pk)
1✔
89
                case ed25519.PrivateKey:
1✔
90
                        pub, _ := pk.Public().(ed25519.PublicKey)
1✔
91
                        if !pub.Equal(actorPubKey) {
1✔
UNCOV
92
                                return keyMismatchErr(pk, actorPubKey)
×
UNCOV
93
                        }
×
94
                        h.Transport.Alg = &ed.Ed25519{PrivateKey: pk, PublicKey: pub}
1✔
95
                }
96
                return nil
4✔
97
        }
98
}
99

100
func WithLogger(l lw.Logger) OptionFn {
1✔
101
        return func(h *HTTPSignatureTransport) error {
2✔
102
                h.l = l
1✔
103
                h.Transport.OnDeriveSigningString = func(ctx context.Context, stringToSign string) {
1✔
UNCOV
104
                        l.Debugf("String to sign: %s", stringToSign)
×
UNCOV
105
                }
×
106
                return nil
1✔
107
        }
108
}
109

110
func WithApplicationTag(t string) OptionFn {
2✔
111
        return func(h *HTTPSignatureTransport) error {
4✔
112
                h.Transport.Tag = t
2✔
113
                return nil
2✔
114
        }
2✔
115
}
116

117
// New initializes the HTTPSignatureTransport
118
// TODO(marius): we need to add to the return values the errors
119
//  that might come from the initialization functions.
120
func New(initFns ...OptionFn) *HTTPSignatureTransport {
1✔
121
        h := new(HTTPSignatureTransport)
1✔
122
        h.Transport.BaseTransport = &http.Transport{}
1✔
123
        h.l = nilLogger
1✔
124
        h.Transport.OnDeriveSigningString = func(_ context.Context, _ string) {}
1✔
125
        for _, fn := range initFns {
4✔
126
                if err := fn(h); err != nil {
3✔
UNCOV
127
                        h.l.Errorf("unable to initialize HTTP Signature transport: %s", err)
×
UNCOV
128
                        return h /*, err*/
×
UNCOV
129
                }
×
130
        }
131
        return h /*, nil*/
1✔
132
}
133

134
type privateKey interface {
135
        Public() crypto.PublicKey
136
}
137

138
func pemEncodePublicKey(prvKey crypto.PrivateKey) string {
1✔
139
        prv, ok := prvKey.(privateKey)
1✔
140
        if !ok {
1✔
UNCOV
141
                return fmt.Sprintf("invalid private key: %T", prvKey)
×
142
        }
×
143
        pubEnc, err := x509.MarshalPKIXPublicKey(prv.Public())
1✔
144
        if err != nil {
1✔
UNCOV
145
                return "invalid public key: %s" + err.Error()
×
UNCOV
146
        }
×
147
        p := pem.Block{
1✔
148
                Type:  "PUBLIC KEY",
1✔
149
                Bytes: pubEnc,
1✔
150
        }
1✔
151
        return strings.ReplaceAll(string(pem.EncodeToMemory(&p)), "\n", "")
1✔
152
}
153

154
// RoundTrip dispatches the received request after signing it
155
func (s *HTTPSignatureTransport) RoundTrip(req *http.Request) (*http.Response, error) {
1✔
156
        or := *req
1✔
157
        isFetchRequest := req.Method == http.MethodGet || req.Method == http.MethodHead
1✔
158

1✔
159
        if req.URL != nil && req.URL.Path == "" {
2✔
160
                req.URL.Path = "/"
1✔
161
        }
1✔
162
        if s.Actor != nil {
2✔
163
                req = cloneRequest(&or) // per RoundTripper contract
1✔
164
                if s.Transport.Alg != nil {
1✔
UNCOV
165
                        // NOTE(marius): we first try signing the request with the RFC9421 compatible mechanism
×
UNCOV
166
                        if isFetchRequest {
×
UNCOV
167
                                s.Transport.CoveredComponents = FetchCoveredComponents
×
168
                        } else {
×
169
                                s.Transport.CoveredComponents = PostCoveredComponents
×
170
                        }
×
171

172
                        res, err := s.Transport.RoundTrip(req)
×
173
                        if err == nil && res.StatusCode < http.StatusBadRequest {
×
174
                                return res, nil
×
175
                        }
×
176
                }
177

178
                // NOTE(marius): if the RFC9421 signed request has failed (possibly due to the server not supporting it)
179
                // we fall back to signing with the draft 6 compatible algorithm.
180
                lctx := lw.Ctx{"key": pemEncodePublicKey(s.Key), "actor": s.Actor.ID}
1✔
181
                if err := s.signRequest(req); err != nil && s.l != nil {
1✔
UNCOV
182
                        s.l.WithContext(lctx, lw.Ctx{"err": err.Error()}).Errorf("unable to sign request")
×
183
                } else {
1✔
184
                        s.l.WithContext(lctx).Debugf("signed request")
1✔
185
                }
1✔
186
        }
187

188
        res1, err := s.BaseTransport.RoundTrip(req)
1✔
189
        // NOTE(marius): if the RoundTrip fails to produce a response and this is a fetch request,
1✔
190
        // we can try again with the original request which doesn't have the Signature header.
1✔
191
        //
1✔
192
        // For the other types of requests that have succeeded, we return now.
1✔
193
        if !isFetchRequest || err == nil {
2✔
194
                return res1, err
1✔
195
        }
1✔
196

197
        // NOTE(marius): if we received an actual error we try the request again, but unsigned.
198
        //
199
        // This is a pretty hacky mitigation for loading Public Keys for Actors on other instances, which
200
        // sometimes triggers a feedback loop if the instance tries to authorize the signing actor in its turn.
201
        //
202
        // I detailed that faulty behaviour in ticket:
203
        //  https://todo.sr.ht/~mariusor/go-activitypub/301
UNCOV
204
        return s.BaseTransport.RoundTrip(&or)
×
205
}
206

207
func toCryptoPublicKey(key vocab.PublicKey) (crypto.PublicKey, error) {
6✔
208
        block, _ := pem.Decode([]byte(key.PublicKeyPem))
6✔
209
        if block == nil {
7✔
210
                return nil, errors.Errorf("invalid PEM decode on public key")
1✔
211
        }
1✔
212
        return x509.ParsePKIXPublicKey(block.Bytes)
5✔
213
}
214

UNCOV
215
func keyMismatchErr(pk crypto.PrivateKey, pub crypto.PublicKey) error {
×
UNCOV
216
        return errors.Newf("unable to sign request, mismatch between the Actor's public and private key: %T : %T", pub, pk)
×
UNCOV
217
}
×
218

219
func (s *HTTPSignatureTransport) signRequest(req *http.Request) error {
1✔
220
        if !s.Actor.PublicKey.ID.IsValid() {
1✔
UNCOV
221
                return errors.Newf("unable to sign request, invalid Actor public key ID")
×
UNCOV
222
        }
×
223

224
        keyID := s.Actor.PublicKey.ID
1✔
225

1✔
226
        headers := HeadersToSign
1✔
227
        bodyBuf := bytes.Buffer{}
1✔
228
        if req.Body != nil {
1✔
UNCOV
229
                if _, err := io.Copy(&bodyBuf, req.Body); err == nil {
×
UNCOV
230
                        req.Body = io.NopCloser(&bodyBuf)
×
UNCOV
231
                        headers = append(HeadersToSign, "digest")
×
UNCOV
232
                }
×
233
        }
234

235
        algos := make([]httpsig.Algorithm, 0)
1✔
236
        switch s.Key.(type) {
1✔
237
        case *rsa.PrivateKey:
1✔
238
                algos = append(algos, httpsig.RSA_SHA256, httpsig.RSA_SHA512)
1✔
UNCOV
239
        case *ecdsa.PrivateKey:
×
UNCOV
240
                algos = append(algos, httpsig.ECDSA_SHA512, httpsig.ECDSA_SHA256)
×
UNCOV
241
        case ed25519.PrivateKey:
×
UNCOV
242
                algos = append(algos, httpsig.ED25519)
×
243
        }
244

245
        // NOTE(marius): The only http-signatures accepted by Mastodon instances is "Signature", not "Authorization"
246
        sig, _, err := httpsig.NewSigner(algos, digestAlgorithm, headers, httpsig.Signature, signatureExpiration)
1✔
247
        if err != nil {
1✔
UNCOV
248
                return err
×
UNCOV
249
        }
×
250
        if err = sig.SignRequest(s.Key, string(keyID), req, bodyBuf.Bytes()); err != nil {
1✔
UNCOV
251
                return err
×
UNCOV
252
        }
×
253
        return nil
1✔
254
}
255

256
var _ http.RoundTripper = new(HTTPSignatureTransport)
257

258
// cloneRequest returns a clone of the provided *http.Request.
259
// The clone is a shallow copy of the struct and its Header map.
260
func cloneRequest(r *http.Request) *http.Request {
1✔
261
        // shallow copy of the struct
1✔
262
        r2 := new(http.Request)
1✔
263
        *r2 = *r
1✔
264
        // deep copy of the Header
1✔
265
        r2.Header = make(http.Header, len(r.Header))
1✔
266
        for k, s := range r.Header {
3✔
267
                r2.Header[k] = append([]string(nil), s...)
2✔
268
        }
2✔
269
        return r2
1✔
270
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc