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

go-ap / client / #24

30 Apr 2026 10:14AM UTC coverage: 81.563% (+0.06%) from 81.5%
#24

push

sourcehut

mariusor
Added test for checking that the round tripper tries with both RFC and draft signatures before giving up

11 of 15 new or added lines in 1 file covered. (73.33%)

10 existing lines in 1 file now uncovered.

960 of 1177 relevant lines covered (81.56%)

7.5 hits per line

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

71.62
/s2s/httpsignatures.go
1
package s2s
2

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

20
        "git.sr.ht/~mariusor/lw"
21
        rfc "github.com/dadrus/httpsig"
22
        vocab "github.com/go-ap/activitypub"
23
        "github.com/go-ap/errors"
24
        draft "github.com/go-fed/httpsig"
25
)
26

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

30
        digestAlgorithm  = draft.DigestSha256
31
        sigValidDuration = time.Hour
32
)
33

34
type Transport struct {
35
        Base http.RoundTripper
36

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

42
        nonceFn           noncer
43
        skipRFCSignatures bool
44

45
        Key   crypto.PrivateKey
46
        Actor *vocab.Actor
47

48
        l lw.Logger
49
}
50

51
type OptionFn func(transport *Transport) error
52

53
func WithTransport(tr http.RoundTripper) OptionFn {
2✔
54
        return func(h *Transport) error {
4✔
55
                h.Base = tr
2✔
56
                return nil
2✔
57
        }
2✔
58
}
59

60
func NoRFC9421(h *Transport) error {
4✔
61
        h.skipRFCSignatures = true
4✔
62
        return nil
4✔
63
}
4✔
64

65
func WithNonce(nonceFn func() (string, error)) OptionFn {
1✔
66
        return func(h *Transport) error {
2✔
67
                h.nonceFn = nonceFn
1✔
68
                return nil
1✔
69
        }
1✔
70
}
71

72
func WithActor(act *vocab.Actor, prv crypto.PrivateKey) OptionFn {
18✔
73
        return func(h *Transport) error {
36✔
74
                h.Actor = act
18✔
75
                h.Key = prv
18✔
76

18✔
77
                return nil
18✔
78
        }
18✔
79
}
80

81
func WithLogger(l lw.Logger) OptionFn {
2✔
82
        return func(h *Transport) error {
4✔
83
                h.l = l
2✔
84
                return nil
2✔
85
        }
2✔
86
}
87

88
func WithApplicationTag(t string) OptionFn {
2✔
89
        return func(h *Transport) error {
4✔
90
                h.Tag = t
2✔
91
                return nil
2✔
92
        }
2✔
93
}
94

95
// New initializes the Transport
96
// TODO(marius): we need to add to the return values the errors
97
//  that might come from the initialization functions.
98
func New(initFns ...OptionFn) *Transport {
13✔
99
        h := new(Transport)
13✔
100
        h.nonceFn = randomNonce
13✔
101
        h.l = nilLogger
13✔
102
        for _, fn := range initFns {
31✔
103
                if err := fn(h); err != nil {
18✔
104
                        h.l.Errorf("unable to initialize HTTP Signature transport: %s", err)
×
105
                        return h /*, err*/
×
106
                }
×
107
        }
108
        return h /*, nil*/
13✔
109
}
110

111
type privateKey interface {
112
        Public() crypto.PublicKey
113
}
114

NEW
115
func pemEncodePrivateKey(prvKey crypto.PrivateKey) string {
×
UNCOV
116
        prv, ok := prvKey.(privateKey)
×
UNCOV
117
        if !ok {
×
118
                return fmt.Sprintf("invalid private key: %T", prvKey)
×
119
        }
×
UNCOV
120
        pubEnc, err := x509.MarshalPKIXPublicKey(prv.Public())
×
UNCOV
121
        if err != nil {
×
122
                return "invalid public key: %s" + err.Error()
×
123
        }
×
UNCOV
124
        p := pem.Block{
×
UNCOV
125
                Type:  "PUBLIC KEY",
×
UNCOV
126
                Bytes: pubEnc,
×
UNCOV
127
        }
×
UNCOV
128
        return strings.ReplaceAll(string(pem.EncodeToMemory(&p)), "\n", "")
×
129
}
130

131
func randomNonce() (string, error) {
5✔
132
        nonceBytes := make([]byte, 32)
5✔
133
        _, err := rand.Read(nonceBytes)
5✔
134
        if err != nil {
5✔
135
                return "", fmt.Errorf("could not generate nonce: %w", err)
×
136
        }
×
137
        return base64.URLEncoding.EncodeToString(nonceBytes), nil
5✔
138
}
139

140
type noncer func() (string, error)
141

142
func (n noncer) GetNonce(_ context.Context) (string, error) {
6✔
143
        return n()
6✔
144
}
6✔
145

146
func validateActorPublicKey(key crypto.PrivateKey, act vocab.Actor) error {
6✔
147
        actorPubKey, err := toCryptoPublicKey(act.PublicKey)
6✔
148
        if err != nil {
6✔
149
                return errors.Annotatef(err, "unable to sign request, Actor public key type %T is invalid", actorPubKey)
×
150
        }
×
151
        switch pk := key.(type) {
6✔
152
        case *rsa.PrivateKey:
2✔
153
                pub := &pk.PublicKey
2✔
154
                if !pub.Equal(actorPubKey) {
2✔
155
                        return keyMismatchErr(pk, actorPubKey)
×
156
                }
×
157
        case *ecdsa.PrivateKey:
1✔
158
                pub, ok := pk.Public().(*ecdsa.PublicKey)
1✔
159
                if !ok || !pub.Equal(actorPubKey) {
1✔
160
                        return keyMismatchErr(pk, actorPubKey)
×
161
                }
×
162
        case ed25519.PrivateKey:
3✔
163
                pub, _ := pk.Public().(ed25519.PublicKey)
3✔
164
                if !pub.Equal(actorPubKey) {
3✔
165
                        return keyMismatchErr(pk, actorPubKey)
×
166
                }
×
167
        }
168
        return nil
6✔
169
}
170

171
func rfcAlgorithmFromPrivateKey(key crypto.PrivateKey) rfc.SignatureAlgorithm {
6✔
172
        // NOTE(marius): I'm not sure what purpose it serves to validate the public key of the actor
6✔
173
        // against the private key
6✔
174
        var alg rfc.SignatureAlgorithm
6✔
175
        switch pk := key.(type) {
6✔
176
        case *rsa.PrivateKey:
2✔
177
                switch pk.PublicKey.Size() {
2✔
178
                case 128, 256:
2✔
179
                        alg = rfc.RsaPkcs1v15Sha256
2✔
180
                case 384:
×
181
                        alg = rfc.RsaPkcs1v15Sha384
×
182
                case 512:
×
183
                        alg = rfc.RsaPkcs1v15Sha512
×
184
                }
185
        case *ecdsa.PrivateKey:
1✔
186
                if p := pk.Params(); p != nil {
2✔
187
                        switch p.BitSize {
1✔
188
                        case 128, 256:
×
189
                                alg = rfc.EcdsaP256Sha256
×
190
                        case 384:
1✔
191
                                alg = rfc.EcdsaP384Sha384
1✔
192
                        case 512:
×
193
                                alg = rfc.EcdsaP521Sha512
×
194
                        }
195
                }
196
        case ed25519.PrivateKey:
3✔
197
                alg = rfc.Ed25519
3✔
198
        }
199
        return alg
6✔
200
}
201

202
func (s *Transport) signRequestRFC(req *http.Request) error {
6✔
203
        if s.Actor == nil {
6✔
204
                return errors.Newf("unable to sign request, Actor is invalid")
×
205
        }
×
206
        if s.Key == nil {
6✔
207
                return errors.Newf("unable to sign request, private key is invalid")
×
208
        }
×
209

210
        if err := validateActorPublicKey(s.Key, *s.Actor); err != nil {
6✔
211
                return errors.Annotatef(err, "unable to sign request, Actor public key does not match it's private key")
×
212
        }
×
213

214
        key := rfc.Key{
6✔
215
                KeyID:     string(s.Actor.PublicKey.ID),
6✔
216
                Algorithm: rfcAlgorithmFromPrivateKey(s.Key),
6✔
217
                Key:       s.Key,
6✔
218
        }
6✔
219

6✔
220
        initFns := []rfc.SignerOption{
6✔
221
                rfc.WithTTL(sigValidDuration),
6✔
222
                rfc.WithNonce(s.nonceFn),
6✔
223
        }
6✔
224

6✔
225
        if s.Tag != "" {
6✔
NEW
226
                initFns = append(initFns, rfc.WithTag(s.Tag))
×
NEW
227
        }
×
228

229
        switch req.Method {
6✔
230
        case http.MethodHead, http.MethodGet:
×
231
                initFns = append(initFns, rfc.WithComponents(FetchCoveredComponents...))
×
232
        case http.MethodPost:
6✔
233
                initFns = append(initFns, rfc.WithComponents(PostCoveredComponents...))
6✔
234
                initFns = append(initFns, rfc.WithContentDigestAlgorithm(rfc.Sha256))
6✔
235
        }
236
        signer, err := rfc.NewSigner(key, initFns...)
6✔
237
        if err != nil {
6✔
238
                return err
×
239
        }
×
240
        postSignHeaders, err := signer.Sign(rfc.MessageFromRequest(req))
6✔
241
        if err != nil {
6✔
242
                return err
×
243
        }
×
244
        req.Header = postSignHeaders
6✔
245
        return nil
6✔
246
}
247

248
var ErrRetry = errors.Newf("retry")
249

250
// RoundTrip dispatches the received request after signing it.
251
// We currently use the double knocking mechanism Mastodon popularized:
252
// * we first attempt to sign the request with RFC9421 compliant signature,
253
// * if it failed, we try again using a draft Cavage-12 version signature.
254
// Additionally, if everything failed, and we're operating with a fetch request,
255
// we make one last, non-signed attempt.
256
func (s *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
11✔
257
        tr := s.Base
11✔
258
        if tr == nil {
20✔
259
                tr = http.DefaultTransport
9✔
260
        }
9✔
261

262
        roundTripFn := func(req *http.Request, signFn func(*http.Request) error) (*http.Response, error) {
24✔
263
                lctx := lw.Ctx{}
13✔
264
                if signFn != nil {
25✔
265
                        lctx["keyType"] = fmt.Sprintf("%T", s.Key)
12✔
266
                        if s.Actor != nil {
24✔
267
                                lctx["actor"] = s.Actor.ID
12✔
268
                                if s.Actor.PublicKey.ID != "" {
24✔
269
                                        lctx["keyID"] = s.Actor.PublicKey.ID
12✔
270
                                }
12✔
271
                        }
272
                        if err := signFn(req); err != nil {
12✔
UNCOV
273
                                if s.l != nil {
×
274
                                        s.l.WithContext(lctx, lw.Ctx{"err": err.Error()}).Errorf("unable to sign request")
×
275
                                }
×
276
                                return nil, ErrRetry
×
277
                        }
278
                }
279

280
                res, err := tr.RoundTrip(req)
13✔
281
                if err != nil {
13✔
282
                        return nil, err
×
283
                }
×
284

285
                switch res.StatusCode {
13✔
286
                case http.StatusUnauthorized, http.StatusForbidden:
3✔
287
                        // NOTE(marius): Not an acceptable response status, so we want to try again.
3✔
288
                        // We also need to close the body of discarded response to avoid leaks.
3✔
289
                        _ = res.Body.Close()
3✔
290
                        if s.l != nil {
6✔
291
                                s.l.WithContext(lctx).Errorf("received %s response", res.Status)
3✔
292
                        }
3✔
293
                        return nil, ErrRetry
3✔
294
                default:
10✔
295
                        // NOTE(marius): some kind of success
10✔
296
                        return res, nil
10✔
297
                }
298
        }
299

300
        or := *req
11✔
301
        isFetchRequest := req.Method == http.MethodGet || req.Method == http.MethodHead
11✔
302

11✔
303
        if or.URL != nil && or.URL.Path == "" {
22✔
304
                or.URL.Path = "/"
11✔
305
        }
11✔
306

307
        var res *http.Response
11✔
308
        var err error
11✔
309
        if s.Actor != nil && s.Key != nil {
21✔
310
                // NOTE(marius): we're to sign the request, so we need to copy the body
10✔
311
                var buff []byte
10✔
312
                if or.Body != nil {
20✔
313
                        if buff, err = io.ReadAll(or.Body); err != nil {
10✔
314
                                return nil, err
×
315
                        }
×
316
                }
317
                if !s.skipRFCSignatures {
16✔
318
                        // NOTE(marius): try #1: use RFC9421 signature
6✔
319
                        res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestRFC)
6✔
320
                        if err == nil || !errors.Is(err, ErrRetry) {
10✔
321
                                return res, err
4✔
322
                        }
4✔
323
                        if res != nil && res.Body != nil {
2✔
324
                                _ = res.Body.Close()
×
325
                        }
×
326
                }
327

328
                // NOTE(marius): try #2: use Cavage-12 draft signature
329
                res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestDraft)
6✔
330
                if err == nil || !errors.Is(err, ErrRetry) {
11✔
331
                        return res, err
5✔
332
                }
5✔
333

334
                // NOTE(marius): if draft signatures failed also, and this is not
335
                // a request that we can retry w/o a signature, we return the resulting
336
                // errors.
337
                if err != nil && errors.Is(err, ErrRetry) && !isFetchRequest {
2✔
338
                        return res, errors.Unauthorizedf("unauthorized")
1✔
339
                }
1✔
340

341
                if res != nil && res.Body != nil {
×
342
                        _ = res.Body.Close()
×
343
                }
×
344
        }
345

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

NEW
354
        return nil, errors.Unauthorizedf("unauthorized")
×
355
}
356

357
func toCryptoPublicKey(key vocab.PublicKey) (crypto.PublicKey, error) {
6✔
358
        block, _ := pem.Decode([]byte(key.PublicKeyPem))
6✔
359
        if block == nil {
6✔
360
                return nil, errors.Errorf("invalid PEM decode on public key")
×
361
        }
×
362
        pk, err := x509.ParsePKIXPublicKey(block.Bytes)
6✔
363
        if err == nil {
11✔
364
                return pk, err
5✔
365
        }
5✔
366
        return x509.ParsePKCS1PublicKey(block.Bytes)
1✔
367
}
368

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

373
func (s *Transport) signRequestDraft(req *http.Request) error {
6✔
374
        if s.Actor == nil {
6✔
375
                return errors.Newf("unable to sign request, Actor is invalid")
×
376
        }
×
377
        if s.Key == nil {
6✔
378
                return errors.Newf("unable to sign request, private key is invalid")
×
379
        }
×
380
        if !s.Actor.PublicKey.ID.IsValid() {
6✔
381
                return errors.Newf("unable to sign request, invalid Actor public key ID")
×
382
        }
×
383

384
        keyID := s.Actor.PublicKey.ID
6✔
385

6✔
386
        headers := HeadersToSign
6✔
387
        bodyBuf := bytes.Buffer{}
6✔
388
        if req.Body != nil {
12✔
389
                if _, err := io.Copy(&bodyBuf, req.Body); err == nil {
12✔
390
                        req.Body = io.NopCloser(&bodyBuf)
6✔
391
                        if bodyBuf.Len() > 0 {
11✔
392
                                headers = append(HeadersToSign, "digest")
5✔
393
                        }
5✔
394
                }
395
        }
396

397
        algos := make([]draft.Algorithm, 0)
6✔
398
        switch pk := s.Key.(type) {
6✔
399
        case *rsa.PrivateKey:
2✔
400
                switch pk.PublicKey.Size() {
2✔
401
                case 128, 256:
2✔
402
                        algos = append(algos, draft.RSA_SHA256)
2✔
403
                case 384:
×
404
                        algos = append(algos, draft.RSA_SHA384)
×
405
                case 512:
×
406
                        algos = append(algos, draft.RSA_SHA512)
×
407
                }
408
        case *ecdsa.PrivateKey:
1✔
409
                if p := pk.Params(); p != nil {
2✔
410
                        switch p.BitSize {
1✔
411
                        case 128, 256:
×
412
                                algos = append(algos, draft.ECDSA_SHA256)
×
413
                        case 384:
1✔
414
                                algos = append(algos, draft.ECDSA_SHA384)
1✔
415
                        case 512:
×
416
                                algos = append(algos, draft.ECDSA_SHA512)
×
417
                        }
418
                }
419
        case ed25519.PrivateKey:
3✔
420
                algos = append(algos, draft.ED25519)
3✔
421
        }
422

423
        secToExpiration := int64(sigValidDuration.Seconds())
6✔
424
        // NOTE(marius): The only http-signatures accepted by Mastodon instances is "Signature", not "Authorization"
6✔
425
        sig, _, err := draft.NewSigner(algos, digestAlgorithm, headers, draft.Signature, secToExpiration)
6✔
426
        if err != nil {
6✔
427
                return err
×
428
        }
×
429
        return sig.SignRequest(s.Key, string(keyID), req, bodyBuf.Bytes())
6✔
430
}
431

432
var _ http.RoundTripper = new(Transport)
433

434
// cloneRequest returns a clone of the provided *http.Request.
435
// The clone is a shallow copy of the struct and its Header map.
436
func cloneRequest(r *http.Request, buff []byte) *http.Request {
12✔
437
        r2 := r.Clone(r.Context())
12✔
438
        if buff != nil {
24✔
439
                r2.Body = io.NopCloser(bytes.NewReader(buff))
12✔
440
        }
12✔
441
        return r2
12✔
442
}
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