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

go-ap / client / #34

03 May 2026 01:34PM UTC coverage: 81.773% (+0.2%) from 81.605%
#34

push

sourcehut

mariusor
Add support for passing the covered components at s2s transport initialization

Use @target-uri as default for prod covered components

13 of 14 new or added lines in 1 file covered. (92.86%)

1 existing line in 1 file now uncovered.

987 of 1207 relevant lines covered (81.77%)

8.83 hits per line

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

74.84
/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
        "slices"
18
        "strings"
19
        "time"
20

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

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

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

35
type Transport struct {
36
        Base http.RoundTripper
37

38
        // RFC9421 relevant data
39
        skipRFCSignatures bool
40
        nonceFn           noncer
41
        coveredComponents []string
42
        // tag is an application-specific tag for the signature as a String value.
43
        // This value is used by applications to help identify signatures relevant for specific applications or protocols.
44
        // See: https://www.rfc-editor.org/rfc/rfc9421.html#section-2.3-4.12
45
        tag string
46

47
        Key   crypto.PrivateKey
48
        Actor *vocab.Actor
49

50
        l lw.Logger
51
}
52

53
type OptionFn func(transport *Transport) error
54

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

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

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

74
func WithCoveredComponents(comp ...string) OptionFn {
1✔
75
        return func(h *Transport) error {
2✔
76
                h.coveredComponents = comp
1✔
77
                return nil
1✔
78
        }
1✔
79
}
80

81
func WithActor(act *vocab.Actor, prv crypto.PrivateKey) OptionFn {
19✔
82
        return func(h *Transport) error {
38✔
83
                h.Actor = act
19✔
84
                h.Key = prv
19✔
85

19✔
86
                return nil
19✔
87
        }
19✔
88
}
89

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

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

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

120
type privateKey interface {
121
        Public() crypto.PublicKey
122
}
123

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

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

149
type noncer func() (string, error)
150

151
func (n noncer) GetNonce(_ context.Context) (string, error) {
7✔
152
        return n()
7✔
153
}
7✔
154

155
func validateActorPublicKey(key crypto.PrivateKey, act vocab.Actor) error {
7✔
156
        actorPubKey, err := toCryptoPublicKey(act.PublicKey)
7✔
157
        if err != nil {
7✔
158
                return errors.Annotatef(err, "unable to sign request, Actor public key type %T is invalid", actorPubKey)
×
159
        }
×
160
        switch pk := key.(type) {
7✔
161
        case *rsa.PrivateKey:
2✔
162
                pub := &pk.PublicKey
2✔
163
                if !pub.Equal(actorPubKey) {
2✔
164
                        return keyMismatchErr(pk, actorPubKey)
×
165
                }
×
166
        case *ecdsa.PrivateKey:
1✔
167
                pub, ok := pk.Public().(*ecdsa.PublicKey)
1✔
168
                if !ok || !pub.Equal(actorPubKey) {
1✔
169
                        return keyMismatchErr(pk, actorPubKey)
×
170
                }
×
171
        case ed25519.PrivateKey:
4✔
172
                pub, _ := pk.Public().(ed25519.PublicKey)
4✔
173
                if !pub.Equal(actorPubKey) {
4✔
174
                        return keyMismatchErr(pk, actorPubKey)
×
175
                }
×
176
        }
177
        return nil
7✔
178
}
179

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

211
func (s *Transport) signRequestRFC(req *http.Request) error {
7✔
212
        if s.Actor == nil {
7✔
213
                return errors.Newf("unable to sign request, Actor is invalid")
×
214
        }
×
215
        if s.Key == nil {
7✔
216
                return errors.Newf("unable to sign request, private key is invalid")
×
217
        }
×
218

219
        if err := validateActorPublicKey(s.Key, *s.Actor); err != nil {
7✔
220
                return errors.Annotatef(err, "unable to sign request, Actor public key does not match it's private key")
×
221
        }
×
222

223
        key := rfc.Key{
7✔
224
                KeyID:     string(s.Actor.PublicKey.ID),
7✔
225
                Algorithm: rfcAlgorithmFromPrivateKey(s.Key),
7✔
226
                Key:       s.Key,
7✔
227
        }
7✔
228

7✔
229
        initFns := []rfc.SignerOption{
7✔
230
                rfc.WithTTL(sigValidDuration),
7✔
231
                rfc.WithNonce(s.nonceFn),
7✔
232
        }
7✔
233

7✔
234
        if s.tag != "" {
7✔
NEW
235
                initFns = append(initFns, rfc.WithTag(s.tag))
×
UNCOV
236
        }
×
237

238
        switch req.Method {
7✔
239
        case http.MethodHead, http.MethodGet:
1✔
240
                initFns = append(initFns, rfc.WithComponents(FetchCoveredComponents...))
1✔
241
        case http.MethodPost:
6✔
242
                initFns = append(initFns, rfc.WithComponents(PostCoveredComponents...))
6✔
243
                initFns = append(initFns, rfc.WithContentDigestAlgorithm(rfc.Sha256))
6✔
244
        }
245
        signer, err := rfc.NewSigner(key, initFns...)
7✔
246
        if err != nil {
7✔
247
                return err
×
248
        }
×
249
        postSignHeaders, err := signer.Sign(rfc.MessageFromRequest(req))
7✔
250
        if err != nil {
7✔
251
                return err
×
252
        }
×
253
        req.Header = postSignHeaders
7✔
254
        return nil
7✔
255
}
256

257
var ErrRetry = errors.Newf("retry")
258

259
// RoundTrip dispatches the received request after signing it.
260
// We currently use the double knocking mechanism Mastodon popularized:
261
// * we first attempt to sign the request with RFC9421 compliant signature,
262
// * if it failed, we try again using a draft Cavage-12 version signature.
263
// Additionally, if everything failed, and we're operating with a fetch request,
264
// we make one last, non-signed attempt.
265
func (s *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
12✔
266
        tr := s.Base
12✔
267
        if tr == nil {
22✔
268
                tr = http.DefaultTransport
10✔
269
        }
10✔
270
        isFetchRequest := slices.Contains([]string{http.MethodGet, http.MethodHead}, req.Method)
12✔
271

12✔
272
        if s.coveredComponents == nil {
24✔
273
                s.coveredComponents = FetchCoveredComponents
12✔
274
                if !isFetchRequest {
21✔
275
                        s.coveredComponents = PostCoveredComponents
9✔
276
                }
9✔
277
        }
278
        roundTripFn := func(req *http.Request, signFn func(*http.Request) error) (*http.Response, error) {
28✔
279
                lctx := lw.Ctx{}
16✔
280
                lastTry := false
16✔
281
                if signFn == nil {
18✔
282
                        lastTry = true
2✔
283
                } else {
16✔
284
                        lctx["key-type"] = fmt.Sprintf("%T", s.Key)
14✔
285
                        if s.Actor != nil {
28✔
286
                                lctx["actor"] = s.Actor.ID
14✔
287
                                if s.Actor.PublicKey.ID != "" {
28✔
288
                                        lctx["key-id"] = s.Actor.PublicKey.ID
14✔
289
                                }
14✔
290
                        }
291
                        if err := signFn(req); err != nil {
14✔
292
                                if s.l != nil {
×
293
                                        s.l.WithContext(lctx, lw.Ctx{"err": err.Error()}).Errorf("unable to sign request")
×
294
                                }
×
295
                                return nil, ErrRetry
×
296
                        }
297
                }
298

299
                res, err := tr.RoundTrip(req)
16✔
300
                if lastTry || err != nil {
18✔
301
                        return res, err
2✔
302
                }
2✔
303

304
                switch res.StatusCode {
14✔
305
                case http.StatusBadRequest:
2✔
306
                        // NOTE(marius): this is a hack for tags.pub that doesn't
2✔
307
                        // return a 403 or 401 error status on failing signatures
2✔
308
                        // See https://todo.sr.ht/~mariusor/go-activitypub/473
2✔
309
                        fallthrough
2✔
310
                case http.StatusUnauthorized, http.StatusForbidden:
5✔
311
                        // NOTE(marius): Not an acceptable response status, so we want to try again.
5✔
312
                        // We also need to close the body of discarded response to avoid leaks.
5✔
313
                        body, _ := io.ReadAll(res.Body)
5✔
314
                        _ = res.Body.Close()
5✔
315
                        s.l.WithContext(lctx).Errorf("received %s response: %s", res.Status, body[:min(512, len(body))])
5✔
316
                        return nil, ErrRetry
5✔
317
                default:
9✔
318
                        // NOTE(marius): some kind of success
9✔
319
                        return res, nil
9✔
320
                }
321
        }
322

323
        or := *req
12✔
324

12✔
325
        if or.URL != nil && or.URL.Path == "" {
24✔
326
                or.URL.Path = "/"
12✔
327
        }
12✔
328

329
        var res *http.Response
12✔
330
        var err error
12✔
331
        if s.Actor != nil && s.Key != nil {
23✔
332
                // NOTE(marius): we're to sign the request, so we need to copy the body
11✔
333
                var buff []byte
11✔
334
                if or.Body != nil {
22✔
335
                        if buff, err = io.ReadAll(or.Body); err != nil {
11✔
336
                                return nil, err
×
337
                        }
×
338
                }
339
                if !s.skipRFCSignatures {
18✔
340
                        // NOTE(marius): try #1: use RFC9421 signature
7✔
341
                        res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestRFC)
7✔
342
                        if err == nil || !errors.Is(err, ErrRetry) {
11✔
343
                                return res, err
4✔
344
                        }
4✔
345
                        if res != nil && res.Body != nil {
3✔
346
                                _ = res.Body.Close()
×
347
                        }
×
348
                }
349

350
                // NOTE(marius): try #2: use Cavage-12 draft signature
351
                res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestDraft)
7✔
352
                if err == nil || !errors.Is(err, ErrRetry) {
12✔
353
                        return res, err
5✔
354
                }
5✔
355

356
                // NOTE(marius): if draft signatures failed also, and this is not
357
                // a request that we can retry w/o a signature, we return the resulting
358
                // errors.
359
                if err != nil && errors.Is(err, ErrRetry) && !isFetchRequest {
3✔
360
                        return res, errors.Unauthorizedf("unauthorized")
1✔
361
                }
1✔
362

363
                if res != nil && res.Body != nil {
1✔
364
                        _ = res.Body.Close()
×
365
                }
×
366
        }
367

368
        if isFetchRequest {
4✔
369
                // NOTE(marius): This is a mitigation for loading Public Keys for Actors on other instances,
2✔
370
                // which can create an infinite loop of requests if that instance tries to do an authorize-fetch
2✔
371
                // for our signing Actor.
2✔
372
                // There are more details in ticket: https://todo.sr.ht/~mariusor/go-activitypub/301
2✔
373
                return roundTripFn(&or, nil)
2✔
374
        }
2✔
375

376
        return nil, errors.Unauthorizedf("unauthorized")
×
377
}
378

379
func toCryptoPublicKey(key vocab.PublicKey) (crypto.PublicKey, error) {
7✔
380
        block, _ := pem.Decode([]byte(key.PublicKeyPem))
7✔
381
        if block == nil {
7✔
382
                return nil, errors.Errorf("invalid PEM decode on public key")
×
383
        }
×
384
        pk, err := x509.ParsePKIXPublicKey(block.Bytes)
7✔
385
        if err == nil {
13✔
386
                return pk, err
6✔
387
        }
6✔
388
        return x509.ParsePKCS1PublicKey(block.Bytes)
1✔
389
}
390

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

395
func (s *Transport) signRequestDraft(req *http.Request) error {
7✔
396
        if s.Actor == nil {
7✔
397
                return errors.Newf("unable to sign request, Actor is invalid")
×
398
        }
×
399
        if s.Key == nil {
7✔
400
                return errors.Newf("unable to sign request, private key is invalid")
×
401
        }
×
402
        if !s.Actor.PublicKey.ID.IsValid() {
7✔
403
                return errors.Newf("unable to sign request, invalid Actor public key ID")
×
404
        }
×
405

406
        keyID := s.Actor.PublicKey.ID
7✔
407

7✔
408
        headers := HeadersToSign
7✔
409
        bodyBuf := bytes.Buffer{}
7✔
410
        if req.Body != nil {
14✔
411
                if _, err := io.Copy(&bodyBuf, req.Body); err == nil {
14✔
412
                        req.Body = io.NopCloser(&bodyBuf)
7✔
413
                        if bodyBuf.Len() > 0 {
12✔
414
                                headers = append(HeadersToSign, "digest")
5✔
415
                        }
5✔
416
                }
417
        }
418

419
        algos := make([]draft.Algorithm, 0)
7✔
420
        switch pk := s.Key.(type) {
7✔
421
        case *rsa.PrivateKey:
2✔
422
                switch pk.PublicKey.Size() {
2✔
423
                case 128, 256:
2✔
424
                        algos = append(algos, draft.RSA_SHA256)
2✔
425
                case 384:
×
426
                        algos = append(algos, draft.RSA_SHA384)
×
427
                case 512:
×
428
                        algos = append(algos, draft.RSA_SHA512)
×
429
                }
430
        case *ecdsa.PrivateKey:
1✔
431
                if p := pk.Params(); p != nil {
2✔
432
                        switch p.BitSize {
1✔
433
                        case 128, 256:
×
434
                                algos = append(algos, draft.ECDSA_SHA256)
×
435
                        case 384:
1✔
436
                                algos = append(algos, draft.ECDSA_SHA384)
1✔
437
                        case 512:
×
438
                                algos = append(algos, draft.ECDSA_SHA512)
×
439
                        }
440
                }
441
        case ed25519.PrivateKey:
4✔
442
                algos = append(algos, draft.ED25519)
4✔
443
        }
444

445
        secToExpiration := int64(sigValidDuration.Seconds())
7✔
446
        // NOTE(marius): The only http-signatures accepted by Mastodon instances is "Signature", not "Authorization"
7✔
447
        sig, _, err := draft.NewSigner(algos, digestAlgorithm, headers, draft.Signature, secToExpiration)
7✔
448
        if err != nil {
7✔
449
                return err
×
450
        }
×
451
        return sig.SignRequest(s.Key, string(keyID), req, bodyBuf.Bytes())
7✔
452
}
453

454
var _ http.RoundTripper = new(Transport)
455

456
// cloneRequest returns a clone of the provided *http.Request.
457
// The clone is a shallow copy of the struct and its Header map.
458
func cloneRequest(r *http.Request, buff []byte) *http.Request {
14✔
459
        r2 := r.Clone(r.Context())
14✔
460
        if buff != nil {
28✔
461
                r2.Body = io.NopCloser(bytes.NewReader(buff))
14✔
462
        }
14✔
463
        return r2
14✔
464
}
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