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

go-ap / client / #56

23 Jun 2026 05:14PM UTC coverage: 80.519% (-0.7%) from 81.206%
#56

push

sourcehut

mariusor
Disable unused lint

1054 of 1309 relevant lines covered (80.52%)

7.52 hits per line

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

78.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/pem"
13
        "fmt"
14
        "io"
15
        "net/http"
16
        "slices"
17
        "time"
18

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

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

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

33
type Transport struct {
34
        Base http.RoundTripper
35

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

45
        Alg   KeyEncoding
46
        Key   crypto.PrivateKey
47
        Actor *vocab.Actor
48

49
        l lw.Logger
50
}
51

52
type OptionFn func(transport *Transport) error
53

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

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

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

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

80
func WithAlg(alg KeyEncoding) OptionFn {
1✔
81
        return func(h *Transport) error {
2✔
82
                h.Alg = alg
1✔
83
                return nil
1✔
84
        }
1✔
85
}
86

87
func WithActor(act *vocab.Actor, prv crypto.PrivateKey) OptionFn {
19✔
88
        return func(h *Transport) error {
38✔
89
                h.Actor = act
19✔
90
                h.Key = prv
19✔
91

19✔
92
                return nil
19✔
93
        }
19✔
94
}
95

96
func WithLogger(l lw.Logger) OptionFn {
2✔
97
        return func(h *Transport) error {
4✔
98
                h.l = l
2✔
99
                return nil
2✔
100
        }
2✔
101
}
102

103
func WithApplicationTag(t string) OptionFn {
2✔
104
        return func(h *Transport) error {
4✔
105
                h.tag = t
2✔
106
                return nil
2✔
107
        }
2✔
108
}
109

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

126
func randomNonce() (string, error) {
×
127
        return rand.Text(), nil
×
128
}
×
129

130
type noncer func() (string, error)
131

132
func (n noncer) GetNonce(_ context.Context) (string, error) {
1✔
133
        return n()
1✔
134
}
1✔
135

136
func validateActorPublicKey(key crypto.PrivateKey, actorPubKey crypto.PublicKey) error {
7✔
137
        switch pk := key.(type) {
7✔
138
        case *rsa.PrivateKey:
2✔
139
                pub := &pk.PublicKey
2✔
140
                if !pub.Equal(actorPubKey) {
2✔
141
                        return keyMismatchErr(pk, actorPubKey)
×
142
                }
×
143
        case *ecdsa.PrivateKey:
1✔
144
                pub, ok := pk.Public().(*ecdsa.PublicKey)
1✔
145
                if !ok || !pub.Equal(actorPubKey) {
1✔
146
                        return keyMismatchErr(pk, actorPubKey)
×
147
                }
×
148
        case ed25519.PrivateKey:
4✔
149
                pub, _ := pk.Public().(ed25519.PublicKey)
4✔
150
                if !pub.Equal(actorPubKey) {
4✔
151
                        return keyMismatchErr(pk, actorPubKey)
×
152
                }
×
153
        }
154
        return nil
7✔
155
}
156

157
func rfcAlgorithmFromPrivateKey(key crypto.PrivateKey, typ KeyEncoding) rfc.SignatureAlgorithm {
7✔
158
        // NOTE(marius): I'm not sure what purpose it serves to validate the public key of the actor
7✔
159
        // against the private key
7✔
160
        var alg rfc.SignatureAlgorithm = ""
7✔
161

7✔
162
        switch pk := key.(type) {
7✔
163
        case *rsa.PrivateKey:
2✔
164
                switch pk.Size() {
2✔
165
                case 128, 256:
2✔
166
                        switch typ {
2✔
167
                        case KeyTypePKCS:
1✔
168
                                alg = rfc.RsaPkcs1v15Sha256
1✔
169
                        case KeyTypePSS:
×
170
                                alg = rfc.RsaPssSha256
×
171
                        }
172
                case 384:
×
173
                        switch typ {
×
174
                        case KeyTypePKCS:
×
175
                                alg = rfc.RsaPkcs1v15Sha384
×
176
                        case KeyTypePSS:
×
177
                                alg = rfc.RsaPssSha384
×
178
                        }
179
                case 512:
×
180
                        switch typ {
×
181
                        case KeyTypePKCS:
×
182
                                alg = rfc.RsaPkcs1v15Sha512
×
183
                        case KeyTypePSS:
×
184
                        }
185
                }
186
        case *ecdsa.PrivateKey:
1✔
187
                if p := pk.Params(); p != nil {
2✔
188
                        switch p.BitSize {
1✔
189
                        case 128, 256:
×
190
                                alg = rfc.EcdsaP256Sha256
×
191
                        case 384:
1✔
192
                                alg = rfc.EcdsaP384Sha384
1✔
193
                        case 512:
×
194
                                alg = rfc.EcdsaP521Sha512
×
195
                        }
196
                }
197
        case ed25519.PrivateKey:
4✔
198
                alg = rfc.Ed25519
4✔
199
        }
200
        return alg
7✔
201
}
202

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

212
                pubKey, err := toCryptoPublicKey(s.Actor.PublicKey)
7✔
213
                if err != nil {
7✔
214
                        return errors.Annotatef(err, "unable to sign request, unable to validate the Actor's public key")
×
215
                }
×
216
                if err = validateActorPublicKey(s.Key, pubKey); err != nil {
7✔
217
                        return errors.Annotatef(err, "unable to sign request, Actor public key does not match it's private key")
×
218
                }
×
219

220
                key := rfc.Key{
7✔
221
                        KeyID:     string(s.Actor.PublicKey.ID),
7✔
222
                        Algorithm: rfcAlgorithmFromPrivateKey(s.Key, s.Alg),
7✔
223
                        Key:       s.Key,
7✔
224
                }
7✔
225

7✔
226
                initFns := []rfc.SignerOption{
7✔
227
                        rfc.WithTTL(sigValidDuration),
7✔
228
                }
7✔
229

7✔
230
                if s.tag != "" {
7✔
231
                        initFns = append(initFns, rfc.WithTag(s.tag))
×
232
                }
×
233

234
                if coveredComponents != nil {
14✔
235
                        initFns = append(initFns, rfc.WithComponents(coveredComponents...))
7✔
236
                }
7✔
237
                if req.Method == http.MethodPost {
13✔
238
                        initFns = append(initFns, rfc.WithContentDigestAlgorithm(rfc.Sha256))
6✔
239
                }
6✔
240
                if s.nonceFn != nil {
8✔
241
                        initFns = append(initFns, rfc.WithNonce(s.nonceFn))
1✔
242
                }
1✔
243
                signer, err := rfc.NewSigner(key, initFns...)
7✔
244
                if err != nil {
8✔
245
                        return err
1✔
246
                }
1✔
247
                msg := rfc.MessageFromRequest(req)
6✔
248
                // NOTE(marius): for some fetch requests, we have a non empty fragment
6✔
249
                // I'm not clear if this case is handled correctly on the verifier side.
6✔
250
                //if msg.URL.Fragment != "" {
6✔
251
                //        req.URL.Fragment = ""
6✔
252
                //}
6✔
253
                postSignHeaders, err := signer.Sign(msg)
6✔
254
                if err != nil {
6✔
255
                        return err
×
256
                }
×
257
                req.Header = postSignHeaders
6✔
258
                return nil
6✔
259
        }
260
}
261

262
var ErrRetry = errors.Newf("retry")
263

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

12✔
277
        coveredComponents := s.coveredComponents
12✔
278
        if coveredComponents == nil {
24✔
279
                // NOTE(marius): ideally the caller knows if we're about to sign a Fetch or not,
12✔
280
                // and provide all necessary covered components at initialization time.
12✔
281
                coveredComponents = FetchCoveredComponents
12✔
282
                if !isFetchRequest {
21✔
283
                        coveredComponents = append(coveredComponents, AdditionalPostCoveredComponents...)
9✔
284
                }
9✔
285
        }
286
        roundTripFn := func(req *http.Request, signFn func(*http.Request) error, l lw.Logger) (*http.Response, error) {
29✔
287
                lastTry := false
17✔
288
                lc := lw.Ctx{}
17✔
289
                if signFn == nil {
19✔
290
                        lastTry = true
2✔
291
                } else {
17✔
292
                        lc["key-type"] = fmt.Sprintf("%T", s.Key)
15✔
293
                        if s.Actor != nil {
30✔
294
                                lc["actor"] = s.Actor.ID
15✔
295
                                if s.Actor.PublicKey.ID != "" {
30✔
296
                                        lc["key-id"] = s.Actor.PublicKey.ID
15✔
297
                                }
15✔
298
                        }
299
                        if err := signFn(req); err != nil {
16✔
300
                                lc["err"] = err
1✔
301
                                l.WithContext(lc).Errorf("failed to sign request")
1✔
302
                                return nil, ErrRetry
1✔
303
                        }
1✔
304
                }
305
                lc["host"] = req.URL.Hostname()
16✔
306

16✔
307
                res, err := tr.RoundTrip(req)
16✔
308
                if lastTry || err != nil {
18✔
309
                        return res, err
2✔
310
                }
2✔
311

312
                switch res.StatusCode {
14✔
313
                case http.StatusServiceUnavailable:
×
314
                        // NOTE(marius): this is a hack for mastoart.social
×
315
                        // which returns a 503 if it encountered previous errors.
×
316
                        fallthrough
×
317
                case http.StatusBadRequest:
2✔
318
                        // NOTE(marius): this is a hack for tags.pub that doesn't
2✔
319
                        // return a 403 or 401 error status on failing signatures
2✔
320
                        // See https://todo.sr.ht/~mariusor/go-activitypub/473
2✔
321
                        fallthrough
2✔
322
                case http.StatusNotFound:
2✔
323
                        // NOTE(marius): many services, among which the GoActivityPub ones, return not found
2✔
324
                        // for resources that are actually forbidden.
2✔
325
                        fallthrough
2✔
326
                case http.StatusUnauthorized, http.StatusForbidden:
5✔
327
                        // NOTE(marius): Not an acceptable response status, so we want to try again.
5✔
328
                        lc["status"] = res.StatusCode
5✔
329
                        l.WithContext(lc).Errorf("error response from remote server")
5✔
330
                        _, _ = io.Copy(io.Discard, res.Body)
5✔
331
                        _ = res.Body.Close()
5✔
332

5✔
333
                        return nil, ErrRetry
5✔
334
                default:
9✔
335
                        // NOTE(marius): some kind of success
9✔
336
                        return res, nil
9✔
337
                }
338
        }
339

340
        or := *req
12✔
341

12✔
342
        if or.URL != nil && or.URL.Path == "" {
24✔
343
                or.URL.Path = "/"
12✔
344
        }
12✔
345

346
        lctx := lw.Ctx{}
12✔
347
        var res *http.Response
12✔
348
        var err error
12✔
349
        if s.Actor != nil && s.Key != nil {
23✔
350
                // NOTE(marius): we're to sign the request, so we need to copy the body
11✔
351
                var buff []byte
11✔
352
                if or.Body != nil {
22✔
353
                        if buff, err = io.ReadAll(or.Body); err != nil {
11✔
354
                                return nil, err
×
355
                        }
×
356
                }
357
                if !s.skipRFCSignatures {
18✔
358
                        lctx["tries"] = 0
7✔
359
                        lctx["sig-alg"] = "rfc9421"
7✔
360
                        // NOTE(marius): try #1: use RFC9421 signature
7✔
361
                        res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestRFC(coveredComponents), s.l.WithContext(lctx))
7✔
362
                        if err == nil || !errors.Is(err, ErrRetry) {
10✔
363
                                return res, err
3✔
364
                        }
3✔
365
                        if res != nil && res.Body != nil {
4✔
366
                                _ = res.Body.Close()
×
367
                        }
×
368
                }
369

370
                // NOTE(marius): try #2: use Cavage-12 draft signature
371
                lctx["tries"] = 1
8✔
372
                lctx["sig-alg"] = "draft"
8✔
373
                res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestDraft, s.l.WithContext(lctx))
8✔
374
                if err == nil || !errors.Is(err, ErrRetry) {
14✔
375
                        return res, err
6✔
376
                }
6✔
377

378
                // NOTE(marius): if draft signatures failed also, and this is not
379
                // a request that we can retry w/o a signature, we return the resulting
380
                // errors.
381
                if err != nil && errors.Is(err, ErrRetry) && !isFetchRequest {
3✔
382
                        return res, errors.Unauthorizedf("unauthorized")
1✔
383
                }
1✔
384

385
                if res != nil && res.Body != nil {
1✔
386
                        _ = res.Body.Close()
×
387
                }
×
388
        }
389

390
        if isFetchRequest {
4✔
391
                lctx["tries"] = 2
2✔
392
                lctx["sig-alg"] = "none"
2✔
393
                // NOTE(marius): This is a mitigation for loading Public Keys for Actors on other instances,
2✔
394
                // which can create an infinite loop of requests if that instance tries to do an authorize-fetch
2✔
395
                // for our signing Actor.
2✔
396
                // There are more details in ticket: https://todo.sr.ht/~mariusor/go-activitypub/301
2✔
397
                return roundTripFn(&or, nil, s.l.WithContext(lctx))
2✔
398
        }
2✔
399

400
        return nil, errors.Unauthorizedf("unauthorized")
×
401
}
402

403
type KeyEncoding int
404

405
const (
406
        KeyTypeUnknown KeyEncoding = 0
407
        KeyTypePKCS    KeyEncoding = 1
408
        KeyTypePSS     KeyEncoding = 2
409
)
410

411
func toCryptoPublicKey(key vocab.PublicKey) (crypto.PublicKey, error) {
7✔
412
        pubBytes, _ := pem.Decode([]byte(key.PublicKeyPem))
7✔
413
        if pubBytes == nil {
7✔
414
                return nil, errors.Newf("unable to decode PEM payload for public key")
×
415
        }
×
416
        pk, _ := x509.ParsePKIXPublicKey(pubBytes.Bytes)
7✔
417
        if pk != nil {
13✔
418
                return pk, nil
6✔
419
        }
6✔
420
        pk, err := x509.ParsePKCS1PublicKey(pubBytes.Bytes)
1✔
421
        return pk, err
1✔
422
}
423

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

428
func (s *Transport) signRequestDraft(req *http.Request) error {
8✔
429
        if s.Actor == nil {
8✔
430
                return errors.Newf("unable to sign request, Actor is invalid")
×
431
        }
×
432
        if s.Key == nil {
8✔
433
                return errors.Newf("unable to sign request, private key is invalid")
×
434
        }
×
435
        if !s.Actor.PublicKey.ID.IsValid() {
8✔
436
                return errors.Newf("unable to sign request, invalid Actor public key ID")
×
437
        }
×
438

439
        keyID := s.Actor.PublicKey.ID
8✔
440

8✔
441
        headers := HeadersToSign
8✔
442
        bodyBuf := bytes.Buffer{}
8✔
443
        if req.Body != nil {
16✔
444
                if _, err := io.Copy(&bodyBuf, req.Body); err == nil {
16✔
445
                        req.Body = io.NopCloser(&bodyBuf)
8✔
446
                        if bodyBuf.Len() > 0 {
14✔
447
                                headers = append(HeadersToSign, "digest")
6✔
448
                        }
6✔
449
                }
450
        }
451

452
        algos := make([]draft.Algorithm, 0)
8✔
453
        switch pk := s.Key.(type) {
8✔
454
        case *rsa.PrivateKey:
3✔
455
                switch pk.Size() {
3✔
456
                case 128, 256:
3✔
457
                        algos = append(algos, draft.RSA_SHA256)
3✔
458
                case 384:
×
459
                        algos = append(algos, draft.RSA_SHA384)
×
460
                case 512:
×
461
                        algos = append(algos, draft.RSA_SHA512)
×
462
                }
463
        case *ecdsa.PrivateKey:
1✔
464
                if p := pk.Params(); p != nil {
2✔
465
                        switch p.BitSize {
1✔
466
                        case 128, 256:
×
467
                                algos = append(algos, draft.ECDSA_SHA256)
×
468
                        case 384:
1✔
469
                                algos = append(algos, draft.ECDSA_SHA384)
1✔
470
                        case 512:
×
471
                                algos = append(algos, draft.ECDSA_SHA512)
×
472
                        }
473
                }
474
        case ed25519.PrivateKey:
4✔
475
                algos = append(algos, draft.ED25519)
4✔
476
        }
477

478
        secToExpiration := int64(sigValidDuration.Seconds())
8✔
479
        // NOTE(marius): The only http-signatures accepted by Mastodon instances is "Signature", not "Authorization"
8✔
480
        sig, _, err := draft.NewSigner(algos, digestAlgorithm, headers, draft.Signature, secToExpiration)
8✔
481
        if err != nil {
8✔
482
                return err
×
483
        }
×
484
        return sig.SignRequest(s.Key, string(keyID), req, bodyBuf.Bytes())
8✔
485
}
486

487
var _ http.RoundTripper = new(Transport)
488

489
// cloneRequest returns a clone of the provided *http.Request.
490
// The clone is a shallow copy of the struct and its Header map.
491
func cloneRequest(r *http.Request, buff []byte) *http.Request {
15✔
492
        r2 := r.Clone(r.Context())
15✔
493
        if buff != nil {
30✔
494
                r2.Body = io.NopCloser(bytes.NewReader(buff))
15✔
495
        }
15✔
496
        return r2
15✔
497
}
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