• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
You are now the owner of this repo.

go-ap / client / #10

16 Apr 2026 12:27PM UTC coverage: 67.918% (+13.5%) from 54.442%
#10

push

sourcehut

mariusor
Refactored ToInbox/ToOutbox

Increased coverage for wrappers around toCollection and other minor functionality
Updated activitypub with changed IsNil check

62 of 68 new or added lines in 2 files covered. (91.18%)

51 existing lines in 2 files now uncovered.

796 of 1172 relevant lines covered (67.92%)

5.63 hits per line

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

73.58
/s2s/httpsignatures.go
1
package s2s
2

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

20
        "git.sr.ht/~mariusor/lw"
21
        e "github.com/common-fate/httpsig/alg_ecdsa"
22
        ed "github.com/common-fate/httpsig/alg_ed25519"
23
        r "github.com/common-fate/httpsig/alg_rsa"
24
        "github.com/common-fate/httpsig/sigbase"
25
        "github.com/common-fate/httpsig/signature"
26
        "github.com/common-fate/httpsig/signer"
27
        "github.com/common-fate/httpsig/sigparams"
28
        "github.com/common-fate/httpsig/sigset"
29
        vocab "github.com/go-ap/activitypub"
30
        "github.com/go-ap/errors"
31
        "github.com/go-fed/httpsig"
32
)
33

34
var (
35
        nilLogger = lw.Dev(lw.SetOutput(io.Discard))
36

37
        digestAlgorithm     = httpsig.DigestSha256
38
        signatureExpiration = int64(time.Hour.Seconds())
39
)
40

41
type Transport struct {
42
        Base http.RoundTripper
43

44
        // Tag is an application-specific tag for the signature as a String value.
45
        // This value is used by applications to help identify signatures relevant for specific applications or protocols.
46
        // See: https://www.rfc-editor.org/rfc/rfc9421.html#section-2.3-4.12
47
        Tag string
48

49
        nonceFn           func() (string, error)
50
        skipRFCSignatures bool
51

52
        Key   crypto.PrivateKey
53
        Actor *vocab.Actor
54

55
        l lw.Logger
56
}
57

58
type OptionFn func(transport *Transport) error
59

60
func WithTransport(tr http.RoundTripper) OptionFn {
2✔
61
        return func(h *Transport) error {
4✔
62
                h.Base = tr
2✔
63
                return nil
2✔
64
        }
2✔
65
}
66

67
func NoRFC9421(h *Transport) error {
4✔
68
        h.skipRFCSignatures = true
4✔
69
        return nil
4✔
70
}
4✔
71

72
func WithNonce(nonceFn func() (string, error)) OptionFn {
1✔
73
        return func(h *Transport) error {
2✔
74
                h.nonceFn = nonceFn
1✔
75
                return nil
1✔
76
        }
1✔
77
}
78

79
func WithActor(act *vocab.Actor, prv crypto.PrivateKey) OptionFn {
16✔
80
        return func(h *Transport) error {
32✔
81
                h.Actor = act
16✔
82
                h.Key = prv
16✔
83

16✔
84
                return nil
16✔
85
        }
16✔
86
}
87

88
func WithLogger(l lw.Logger) OptionFn {
2✔
89
        return func(h *Transport) error {
4✔
90
                h.l = l
2✔
91
                return nil
2✔
92
        }
2✔
93
}
94

95
func WithApplicationTag(t string) OptionFn {
2✔
96
        return func(h *Transport) error {
4✔
97
                h.Tag = t
2✔
98
                return nil
2✔
99
        }
2✔
100
}
101

102
// New initializes the Transport
103
// TODO(marius): we need to add to the return values the errors
104
//  that might come from the initialization functions.
105
func New(initFns ...OptionFn) *Transport {
11✔
106
        h := new(Transport)
11✔
107
        h.nonceFn = randomNonce
11✔
108
        h.l = nilLogger
11✔
109
        for _, fn := range initFns {
27✔
110
                if err := fn(h); err != nil {
16✔
UNCOV
111
                        h.l.Errorf("unable to initialize HTTP Signature transport: %s", err)
×
UNCOV
112
                        return h /*, err*/
×
UNCOV
113
                }
×
114
        }
115
        return h /*, nil*/
11✔
116
}
117

118
type privateKey interface {
119
        Public() crypto.PublicKey
120
}
121

122
func pemEncodePublicKey(prvKey crypto.PrivateKey) string {
8✔
123
        prv, ok := prvKey.(privateKey)
8✔
124
        if !ok {
8✔
UNCOV
125
                return fmt.Sprintf("invalid private key: %T", prvKey)
×
UNCOV
126
        }
×
127
        pubEnc, err := x509.MarshalPKIXPublicKey(prv.Public())
8✔
128
        if err != nil {
8✔
UNCOV
129
                return "invalid public key: %s" + err.Error()
×
UNCOV
130
        }
×
131
        p := pem.Block{
8✔
132
                Type:  "PUBLIC KEY",
8✔
133
                Bytes: pubEnc,
8✔
134
        }
8✔
135
        return strings.ReplaceAll(string(pem.EncodeToMemory(&p)), "\n", "")
8✔
136
}
137

138
var getCurrentTime = func() time.Time {
4✔
139
        return time.Now().Truncate(time.Millisecond).UTC()
4✔
140
}
4✔
141

142
func randomNonce() (string, error) {
3✔
143
        nonceBytes := make([]byte, 32)
3✔
144
        _, err := rand.Read(nonceBytes)
3✔
145
        if err != nil {
3✔
UNCOV
146
                return "", fmt.Errorf("could not generate nonce: %w", err)
×
UNCOV
147
        }
×
148
        return base64.URLEncoding.EncodeToString(nonceBytes), nil
3✔
149
}
150

151
func (s *Transport) signRequestRFC(req *http.Request) error {
4✔
152
        if s.Actor == nil {
4✔
UNCOV
153
                return errors.Newf("unable to sign request, Actor is invalid")
×
UNCOV
154
        }
×
155
        if s.Key == nil {
4✔
156
                return errors.Newf("unable to sign request, private key is invalid")
×
UNCOV
157
        }
×
158

159
        act := s.Actor
4✔
160
        prv := s.Key
4✔
161
        actorPubKey, err := toCryptoPublicKey(act.PublicKey)
4✔
162
        if err != nil {
4✔
163
                return errors.Annotatef(err, "unable to sign request, Actor public key type %T is invalid", actorPubKey)
×
UNCOV
164
        }
×
165
        keyID := string(act.PublicKey.ID)
4✔
166

4✔
167
        var alg signer.Algorithm
4✔
168
        switch pk := prv.(type) {
4✔
169
        case *rsa.PrivateKey:
2✔
170
                pub, _ := pk.Public().(*rsa.PublicKey)
2✔
171
                if !pub.Equal(actorPubKey) {
2✔
172
                        return keyMismatchErr(pk, actorPubKey)
×
173
                }
×
174
                alg = r.NewRSAPKCS256Signer(pk)
2✔
175
        case *ecdsa.PrivateKey:
1✔
176
                pub, _ := pk.Public().(*ecdsa.PublicKey)
1✔
177
                if !pub.Equal(actorPubKey) {
1✔
UNCOV
178
                        return keyMismatchErr(pk, actorPubKey)
×
UNCOV
179
                }
×
180
                alg = e.NewP384Signer(pk)
1✔
181
        case ed25519.PrivateKey:
1✔
182
                pub, _ := pk.Public().(ed25519.PublicKey)
1✔
183
                if !pub.Equal(actorPubKey) {
1✔
184
                        return keyMismatchErr(pk, actorPubKey)
×
185
                }
×
186
                alg = &ed.Ed25519{PrivateKey: pk, PublicKey: pub}
1✔
187
        }
188
        // parse the existing signature set on the request
189
        set, err := sigset.Unmarshal(req)
4✔
190
        if err != nil {
4✔
UNCOV
191
                return err
×
UNCOV
192
        }
×
193

194
        // derive the signature.
195
        nonce, err := s.nonceFn()
4✔
196
        if err != nil {
4✔
UNCOV
197
                return fmt.Errorf("generating nonce: %w", err)
×
UNCOV
198
        }
×
199

200
        coveredComponents := PostCoveredComponents
4✔
201
        switch req.Method {
4✔
202
        case http.MethodHead, http.MethodGet:
3✔
203
                coveredComponents = FetchCoveredComponents
3✔
204
        }
205
        params := sigparams.Params{
4✔
206
                KeyID:             keyID,
4✔
207
                Tag:               s.Tag,
4✔
208
                Alg:               alg.Type(),
4✔
209
                Created:           getCurrentTime(),
4✔
210
                CoveredComponents: coveredComponents,
4✔
211
                Nonce:             nonce,
4✔
212
        }
4✔
213

4✔
214
        // derive the signature base following the process in https://www.rfc-editor.org/rfc/rfc9421.html#create-sig-input
4✔
215
        base, err := sigbase.Derive(params, nil, req, alg.ContentDigest())
4✔
216
        if err != nil {
4✔
UNCOV
217
                return fmt.Errorf("deriving signature base: %w", err)
×
UNCOV
218
        }
×
219

220
        stringToSign, err := base.CanonicalString(params)
4✔
221
        if err != nil {
4✔
222
                return fmt.Errorf("creating string to sign: %w", err)
×
UNCOV
223
        }
×
224
        // sign the signature base according to the signing algorithm
225
        sig, err := alg.Sign(req.Context(), stringToSign)
4✔
226
        if err != nil {
4✔
227
                return fmt.Errorf("error signing request: %w", err)
×
UNCOV
228
        }
×
229

230
        // construct the HTTP message signature
231
        ms := signature.Message{Input: params, Signature: sig}
4✔
232

4✔
233
        // add the signature to the set
4✔
234
        set.Add(&ms)
4✔
235

4✔
236
        // include the signature in the cloned HTTP request.
4✔
237
        return set.Include(req)
4✔
238
}
239

240
var ErrRetry = errors.Newf("retry")
241

242
// RoundTrip dispatches the received request after signing it.
243
// We currently use the double knocking mechanism Mastodon popularized:
244
// * we first attempt to sign the request with RFC9421 compliant signature,
245
// * if it failed, we try again using a draft Cavage-12 version signature.
246
// Additionally, if everything failed, and we're operating with a fetch request,
247
// we make one last, non-signed attempt.
248
func (s *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
9✔
249
        tr := s.Base
9✔
250
        if tr == nil {
16✔
251
                tr = http.DefaultTransport
7✔
252
        }
7✔
253

254
        errs := make([]error, 0, 3)
9✔
255
        roundTripFn := func(req *http.Request, signFn func(*http.Request) error) (*http.Response, error) {
18✔
256
                if signFn != nil {
17✔
257
                        lctx := lw.Ctx{"key": pemEncodePublicKey(s.Key)}
8✔
258
                        if s.Actor != nil {
16✔
259
                                lctx["actor"] = s.Actor.ID
8✔
260
                        }
8✔
261
                        err := signFn(req)
8✔
262
                        if err != nil {
8✔
UNCOV
263
                                if s.l != nil {
×
UNCOV
264
                                        s.l.WithContext(lctx, lw.Ctx{"err": err.Error()}).Errorf("unable to sign request")
×
UNCOV
265
                                }
×
UNCOV
266
                                errs = append(errs, err)
×
267
                                return nil, ErrRetry
×
268
                        }
269
                }
270

271
                res, err := tr.RoundTrip(req)
9✔
272
                if err != nil {
9✔
UNCOV
273
                        return nil, err
×
UNCOV
274
                }
×
275

276
                switch res.StatusCode {
9✔
277
                case http.StatusUnauthorized, http.StatusForbidden:
×
278
                        // NOTE(marius): Not an acceptable response status, so we want to try again.
×
UNCOV
279
                        // We also need to close the body of discarded response to avoid leaks.
×
UNCOV
280
                        _ = res.Body.Close()
×
281
                        return nil, ErrRetry
×
282
                default:
9✔
283
                        // NOTE(marius): some kind of success
9✔
284
                        return res, nil
9✔
285
                }
286
        }
287

288
        or := *req
9✔
289
        isFetchRequest := req.Method == http.MethodGet || req.Method == http.MethodHead
9✔
290

9✔
291
        if or.URL != nil && or.URL.Path == "" {
18✔
292
                or.URL.Path = "/"
9✔
293
        }
9✔
294

295
        var res *http.Response
9✔
296
        var err error
9✔
297
        if s.Actor != nil && s.Key != nil {
17✔
298
                // NOTE(marius): we're to sign the request, so we need to copy the body
8✔
299
                var buff []byte
8✔
300
                if or.Body != nil {
16✔
301
                        if buff, err = io.ReadAll(or.Body); err != nil {
8✔
UNCOV
302
                                return nil, err
×
UNCOV
303
                        }
×
304
                }
305
                if !s.skipRFCSignatures {
12✔
306
                        // NOTE(marius): try #1: use RFC9421 signature
4✔
307
                        res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestRFC)
4✔
308
                        if err == nil || !errors.Is(err, ErrRetry) {
8✔
309
                                return res, err
4✔
310
                        }
4✔
UNCOV
311
                        if res != nil && res.Body != nil {
×
UNCOV
312
                                _ = res.Body.Close()
×
UNCOV
313
                        }
×
314
                }
315

316
                // NOTE(marius): try #2: use Cavage-12 draft signature
317
                res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestCavage)
4✔
318
                if err == nil || !errors.Is(err, ErrRetry) {
8✔
319
                        return res, err
4✔
320
                }
4✔
UNCOV
321
                if err != nil && errors.Is(err, ErrRetry) && !isFetchRequest {
×
UNCOV
322
                        slices.Reverse(errs)
×
UNCOV
323
                        err = errs[0]
×
UNCOV
324
                        return res, err
×
325
                }
×
326
                if res != nil && res.Body != nil {
×
327
                        _ = res.Body.Close()
×
328
                }
×
329
        }
330

331
        if isFetchRequest {
2✔
332
                // NOTE(marius): This is a mitigation for loading Public Keys for Actors on other instances,
1✔
333
                // which can create an infinite loop of requests if that instance tries to do an authorize-fetch
1✔
334
                // for our signing Actor.
1✔
335
                // There are more details in ticket: https://todo.sr.ht/~mariusor/go-activitypub/301
1✔
336
                return roundTripFn(&or, nil)
1✔
337
        }
1✔
338

UNCOV
339
        return res, errors.Join(errs...)
×
340
}
341

342
func toCryptoPublicKey(key vocab.PublicKey) (crypto.PublicKey, error) {
4✔
343
        block, _ := pem.Decode([]byte(key.PublicKeyPem))
4✔
344
        if block == nil {
4✔
UNCOV
345
                return nil, errors.Errorf("invalid PEM decode on public key")
×
UNCOV
346
        }
×
347
        return x509.ParsePKIXPublicKey(block.Bytes)
4✔
348
}
349

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

354
func (s *Transport) signRequestCavage(req *http.Request) error {
4✔
355
        if s.Actor == nil {
4✔
356
                return errors.Newf("unable to sign request, Actor is invalid")
×
UNCOV
357
        }
×
358
        if s.Key == nil {
4✔
UNCOV
359
                return errors.Newf("unable to sign request, private key is invalid")
×
360
        }
×
361
        if !s.Actor.PublicKey.ID.IsValid() {
4✔
UNCOV
362
                return errors.Newf("unable to sign request, invalid Actor public key ID")
×
363
        }
×
364

365
        keyID := s.Actor.PublicKey.ID
4✔
366

4✔
367
        headers := HeadersToSign
4✔
368
        bodyBuf := bytes.Buffer{}
4✔
369
        if req.Body != nil {
8✔
370
                if _, err := io.Copy(&bodyBuf, req.Body); err == nil {
8✔
371
                        req.Body = io.NopCloser(&bodyBuf)
4✔
372
                        if bodyBuf.Len() > 0 {
7✔
373
                                headers = append(HeadersToSign, "digest")
3✔
374
                        }
3✔
375
                }
376
        }
377

378
        algos := make([]httpsig.Algorithm, 0)
4✔
379
        switch s.Key.(type) {
4✔
380
        case *rsa.PrivateKey:
2✔
381
                algos = append(algos, httpsig.RSA_SHA256, httpsig.RSA_SHA512)
2✔
382
        case *ecdsa.PrivateKey:
1✔
383
                algos = append(algos, httpsig.ECDSA_SHA512, httpsig.ECDSA_SHA256)
1✔
384
        case ed25519.PrivateKey:
1✔
385
                algos = append(algos, httpsig.ED25519)
1✔
386
        }
387

388
        // NOTE(marius): The only http-signatures accepted by Mastodon instances is "Signature", not "Authorization"
389
        sig, _, err := httpsig.NewSigner(algos, digestAlgorithm, headers, httpsig.Signature, signatureExpiration)
4✔
390
        if err != nil {
4✔
UNCOV
391
                return err
×
UNCOV
392
        }
×
393
        return sig.SignRequest(s.Key, string(keyID), req, bodyBuf.Bytes())
4✔
394
}
395

396
var _ http.RoundTripper = new(Transport)
397

398
// cloneRequest returns a clone of the provided *http.Request.
399
// The clone is a shallow copy of the struct and its Header map.
400
func cloneRequest(r *http.Request, buff []byte) *http.Request {
8✔
401
        r2 := r.Clone(r.Context())
8✔
402
        if buff != nil {
16✔
403
                r2.Body = io.NopCloser(bytes.NewReader(buff))
8✔
404
        }
8✔
405
        return r2
8✔
406
}
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