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

go-ap / client / #6

13 Apr 2026 02:38PM UTC coverage: 40.232% (+4.6%) from 35.658%
#6

push

sourcehut

mariusor
More tests for proxy and client

5 of 5 new or added lines in 2 files covered. (100.0%)

31 existing lines in 1 file now uncovered.

416 of 1034 relevant lines covered (40.23%)

1.41 hits per line

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

75.56
/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
        "sync"
17
        "time"
18

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

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

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

36
type Transport struct {
37
        signer.Transport
38

39
        // m is needed for ensuring the signer.Transport CoveredComponents writes and reads
40
        // don't cause a race condition.
41
        m     sync.RWMutex
42
        Key   crypto.PrivateKey
43
        Actor *vocab.Actor
44

45
        l lw.Logger
46
}
47

48
type OptionFn func(transport *Transport) error
49

50
func WithTransport(tr http.RoundTripper) OptionFn {
2✔
51
        return func(h *Transport) error {
4✔
52
                h.Transport.BaseTransport = tr
2✔
53
                return nil
2✔
54
        }
2✔
55
}
56

57
func NoRFC9421(h *Transport) error {
1✔
58
        h.Transport.Alg = nil
1✔
59
        return nil
1✔
60
}
1✔
61

62
func WithCoveredComponents(s ...string) OptionFn {
×
63
        return func(h *Transport) error {
×
64
                h.Transport.CoveredComponents = s
×
65
                return nil
×
66
        }
×
67
}
68
func WithNonce(nonceFn func() (string, error)) OptionFn {
1✔
69
        return func(h *Transport) error {
2✔
70
                h.Transport.GetNonce = nonceFn
1✔
71
                return nil
1✔
72
        }
1✔
73
}
74

75
func WithActor(act *vocab.Actor, prv crypto.PrivateKey) OptionFn {
11✔
76
        return func(h *Transport) error {
22✔
77
                h.Actor = act
11✔
78
                h.Key = prv
11✔
79

11✔
80
                if act == nil {
13✔
81
                        return nil
2✔
82
                }
2✔
83

84
                actorPubKey, err := toCryptoPublicKey(act.PublicKey)
9✔
85
                if err != nil {
10✔
86
                        return errors.Annotatef(err, "unable to sign request, Actor public key type %T is invalid", actorPubKey)
1✔
87
                }
1✔
88

89
                h.Transport.KeyID = string(act.PublicKey.ID)
8✔
90
                if prv == nil {
9✔
91
                        return nil
1✔
92
                }
1✔
93
                switch pk := prv.(type) {
7✔
94
                case *rsa.PrivateKey:
3✔
95
                        pub, _ := pk.Public().(*rsa.PublicKey)
3✔
96
                        if !pub.Equal(actorPubKey) {
3✔
97
                                return keyMismatchErr(pk, actorPubKey)
×
98
                        }
×
99
                        h.Transport.Alg = r.NewRSAPKCS256Signer(pk)
3✔
100
                case *ecdsa.PrivateKey:
1✔
101
                        pub, _ := pk.Public().(*ecdsa.PublicKey)
1✔
102
                        if !pub.Equal(actorPubKey) {
1✔
103
                                return keyMismatchErr(pk, actorPubKey)
×
104
                        }
×
105
                        h.Transport.Alg = e.NewP384Signer(pk)
1✔
106
                case ed25519.PrivateKey:
3✔
107
                        pub, _ := pk.Public().(ed25519.PublicKey)
3✔
108
                        if !pub.Equal(actorPubKey) {
3✔
UNCOV
109
                                return keyMismatchErr(pk, actorPubKey)
×
UNCOV
110
                        }
×
111
                        h.Transport.Alg = &ed.Ed25519{PrivateKey: pk, PublicKey: pub}
3✔
112
                }
113
                return nil
7✔
114
        }
115
}
116

117
func WithLogger(l lw.Logger) OptionFn {
2✔
118
        return func(h *Transport) error {
4✔
119
                h.l = l
2✔
120
                if l != nil {
3✔
121
                        h.Transport.OnDeriveSigningString = func(ctx context.Context, stringToSign string) {
2✔
122
                                l.Debugf("String to sign: %s", stringToSign)
1✔
123
                        }
1✔
124
                }
125
                return nil
2✔
126
        }
127
}
128

129
func WithApplicationTag(t string) OptionFn {
2✔
130
        return func(h *Transport) error {
4✔
131
                h.Transport.Tag = t
2✔
132
                return nil
2✔
133
        }
2✔
134
}
135

136
// New initializes the Transport
137
// TODO(marius): we need to add to the return values the errors
138
//  that might come from the initialization functions.
139
func New(initFns ...OptionFn) *Transport {
7✔
140
        h := new(Transport)
7✔
141
        h.Transport.CoveredComponents = FetchCoveredComponents
7✔
142
        h.l = nilLogger
7✔
143
        for _, fn := range initFns {
15✔
144
                if err := fn(h); err != nil {
8✔
UNCOV
145
                        h.l.Errorf("unable to initialize HTTP Signature transport: %s", err)
×
UNCOV
146
                        return h /*, err*/
×
UNCOV
147
                }
×
148
        }
149
        return h /*, nil*/
7✔
150
}
151

152
type privateKey interface {
153
        Public() crypto.PublicKey
154
}
155

156
func pemEncodePublicKey(prvKey crypto.PrivateKey) string {
1✔
157
        prv, ok := prvKey.(privateKey)
1✔
158
        if !ok {
1✔
UNCOV
159
                return fmt.Sprintf("invalid private key: %T", prvKey)
×
UNCOV
160
        }
×
161
        pubEnc, err := x509.MarshalPKIXPublicKey(prv.Public())
1✔
162
        if err != nil {
1✔
UNCOV
163
                return "invalid public key: %s" + err.Error()
×
UNCOV
164
        }
×
165
        p := pem.Block{
1✔
166
                Type:  "PUBLIC KEY",
1✔
167
                Bytes: pubEnc,
1✔
168
        }
1✔
169
        return strings.ReplaceAll(string(pem.EncodeToMemory(&p)), "\n", "")
1✔
170
}
171

172
// RoundTrip dispatches the received request after signing it.
173
// We currently use the double knocking mechanism Mastodon popularized:
174
// * we first attempt to sign the request with RFC9421 compliant signature,
175
// * if it failed, we try again using a draft Cavage-12 version signature.
176
// Additionally, if everything failed, and we're operating with a fetch request,
177
// we make one last, non-signed attempt.
178
func (s *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
5✔
179
        if s.Transport.BaseTransport == nil {
8✔
180
                s.Transport.BaseTransport = http.DefaultTransport
3✔
181
        }
3✔
182
        tr := s.Transport.BaseTransport
5✔
183
        or := *req
5✔
184
        isFetchRequest := req.Method == http.MethodGet || req.Method == http.MethodHead
5✔
185

5✔
186
        if or.URL != nil && or.URL.Path == "" {
10✔
187
                or.URL.Path = "/"
5✔
188
        }
5✔
189

190
        if s.Actor != nil {
8✔
191
                if s.Transport.Alg != nil {
5✔
192
                        // NOTE(marius): we're to sign the request, so we need to drain the request body
2✔
193
                        var buff []byte
2✔
194
                        if or.Body != nil {
4✔
195
                                var err error
2✔
196
                                if buff, err = io.ReadAll(or.Body); err != nil {
2✔
UNCOV
197
                                        return nil, err
×
UNCOV
198
                                }
×
199
                        }
200

201
                        if res, err := s.Transport.RoundTrip(cloneRequest(&or, buff)); err == nil {
4✔
202
                                if res.StatusCode < http.StatusBadRequest {
4✔
203
                                        return res, nil
2✔
204
                                }
2✔
205
                                // NOTE(marius): Not an acceptable response, so we want to try again.
206
                                // We also need to close the body of discarded response to avoid leaks.
UNCOV
207
                                _ = res.Body.Close()
×
208
                        }
209
                        // NOTE(marius): we clone another request to provide a readable body.
UNCOV
210
                        req = cloneRequest(&or, buff)
×
211
                }
212

213
                // NOTE(marius): when the RFC9421 signed request has failed (possibly due to the server not supporting it)
214
                // we fall back to signing with the Cavage-12 compatible algorithm.
215
                lctx := lw.Ctx{"key": pemEncodePublicKey(s.Key), "actor": s.Actor.ID}
1✔
216
                if err := s.signRequest(req); err != nil && s.l != nil {
1✔
UNCOV
217
                        s.l.WithContext(lctx, lw.Ctx{"err": err.Error()}).Errorf("unable to sign request")
×
218
                } else {
1✔
219
                        s.l.WithContext(lctx).Debugf("signed request")
1✔
220
                }
1✔
221
        }
222

223
        res, err := tr.RoundTrip(req)
3✔
224
        // NOTE(marius): if the RoundTrip fails to produce a response and this is a fetch request,
3✔
225
        // we can try again with the original non-signed request.
3✔
226
        if err != nil && isFetchRequest {
3✔
UNCOV
227
                // NOTE(marius): This is a mitigation for loading Public Keys for Actors on other instances,
×
UNCOV
228
                // which can create an infinite loop of requests if that instance tries to do an authorize-fetch
×
229
                // for our signing Actor.
×
230
                // There are more details in ticket: https://todo.sr.ht/~mariusor/go-activitypub/301
×
UNCOV
231
                return tr.RoundTrip(cloneRequest(&or, nil))
×
UNCOV
232
        }
×
233

234
        // For the other types of requests that have succeeded, we return now.
235
        return res, err
3✔
236
}
237

238
func toCryptoPublicKey(key vocab.PublicKey) (crypto.PublicKey, error) {
9✔
239
        block, _ := pem.Decode([]byte(key.PublicKeyPem))
9✔
240
        if block == nil {
10✔
241
                return nil, errors.Errorf("invalid PEM decode on public key")
1✔
242
        }
1✔
243
        return x509.ParsePKIXPublicKey(block.Bytes)
8✔
244
}
245

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

250
func (s *Transport) signRequest(req *http.Request) error {
1✔
251
        if !s.Actor.PublicKey.ID.IsValid() {
1✔
UNCOV
252
                return errors.Newf("unable to sign request, invalid Actor public key ID")
×
UNCOV
253
        }
×
254

255
        keyID := s.Actor.PublicKey.ID
1✔
256

1✔
257
        headers := HeadersToSign
1✔
258
        bodyBuf := bytes.Buffer{}
1✔
259
        if req.Body != nil {
2✔
260
                if _, err := io.Copy(&bodyBuf, req.Body); err == nil {
2✔
261
                        req.Body = io.NopCloser(&bodyBuf)
1✔
262
                        if bodyBuf.Len() > 0 {
1✔
UNCOV
263
                                headers = append(HeadersToSign, "digest")
×
UNCOV
264
                        }
×
265
                }
266
        }
267

268
        algos := make([]httpsig.Algorithm, 0)
1✔
269
        switch s.Key.(type) {
1✔
270
        case *rsa.PrivateKey:
1✔
271
                algos = append(algos, httpsig.RSA_SHA256, httpsig.RSA_SHA512)
1✔
UNCOV
272
        case *ecdsa.PrivateKey:
×
UNCOV
273
                algos = append(algos, httpsig.ECDSA_SHA512, httpsig.ECDSA_SHA256)
×
UNCOV
274
        case ed25519.PrivateKey:
×
UNCOV
275
                algos = append(algos, httpsig.ED25519)
×
276
        }
277

278
        // NOTE(marius): The only http-signatures accepted by Mastodon instances is "Signature", not "Authorization"
279
        sig, _, err := httpsig.NewSigner(algos, digestAlgorithm, headers, httpsig.Signature, signatureExpiration)
1✔
280
        if err != nil {
1✔
UNCOV
281
                return err
×
UNCOV
282
        }
×
283
        if err = sig.SignRequest(s.Key, string(keyID), req, bodyBuf.Bytes()); err != nil {
1✔
UNCOV
284
                return err
×
UNCOV
285
        }
×
286
        return nil
1✔
287
}
288

289
var _ http.RoundTripper = new(Transport)
290

291
// cloneRequest returns a clone of the provided *http.Request.
292
// The clone is a shallow copy of the struct and its Header map.
293
func cloneRequest(r *http.Request, buff []byte) *http.Request {
2✔
294
        r2 := r.Clone(r.Context())
2✔
295
        if buff != nil {
4✔
296
                r2.Body = io.NopCloser(bytes.NewReader(buff))
2✔
297
        }
2✔
298
        return r2
2✔
299
}
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