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

go-ap / client / #62

02 Jul 2026 02:11PM UTC coverage: 79.787% (-0.7%) from 80.519%
#62

push

sourcehut

mariusor
Use PKCS as default for signing if nothing was explicitly set

4 of 12 new or added lines in 1 file covered. (33.33%)

8 existing lines in 1 file now uncovered.

1050 of 1316 relevant lines covered (79.79%)

6.78 hits per line

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

76.14
/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✔
UNCOV
167
                        case KeyTypePSS:
×
UNCOV
168
                                alg = rfc.RsaPssSha256
×
169
                        case KeyTypePKCS:
1✔
170
                                fallthrough
1✔
171
                        default:
2✔
172
                                alg = rfc.RsaPkcs1v15Sha256
2✔
173
                        }
174
                case 384:
×
175
                        switch typ {
×
176
                        case KeyTypePSS:
×
177
                                alg = rfc.RsaPssSha384
×
NEW
178
                        case KeyTypePKCS:
×
NEW
179
                                fallthrough
×
NEW
180
                        default:
×
NEW
181
                                alg = rfc.RsaPkcs1v15Sha384
×
182
                        }
183
                case 512:
×
184
                        switch typ {
×
NEW
185
                        case KeyTypePSS:
×
NEW
186
                                alg = rfc.RsaPssSha512
×
187
                        case KeyTypePKCS:
×
NEW
188
                                fallthrough
×
NEW
189
                        default:
×
190
                                alg = rfc.RsaPkcs1v15Sha512
×
191
                        }
192
                }
193
        case *ecdsa.PrivateKey:
1✔
194
                if p := pk.Params(); p != nil {
2✔
195
                        switch p.BitSize {
1✔
196
                        case 128, 256:
×
197
                                alg = rfc.EcdsaP256Sha256
×
198
                        case 384:
1✔
199
                                alg = rfc.EcdsaP384Sha384
1✔
200
                        case 512:
×
201
                                alg = rfc.EcdsaP521Sha512
×
202
                        }
203
                }
204
        case ed25519.PrivateKey:
4✔
205
                alg = rfc.Ed25519
4✔
206
        }
207
        return alg
7✔
208
}
209

210
func (s *Transport) signRequestRFC(coveredComponents []string) func(req *http.Request) error {
7✔
211
        return func(req *http.Request) error {
14✔
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
                pubKey, err := toCryptoPublicKey(s.Actor.PublicKey)
7✔
220
                if err != nil {
7✔
221
                        return errors.Annotatef(err, "unable to sign request, unable to validate the Actor's public key")
×
222
                }
×
223
                if err = validateActorPublicKey(s.Key, pubKey); err != nil {
7✔
224
                        return errors.Annotatef(err, "unable to sign request, Actor public key does not match it's private key")
×
225
                }
×
226

227
                key := rfc.Key{
7✔
228
                        KeyID:     string(s.Actor.PublicKey.ID),
7✔
229
                        Algorithm: rfcAlgorithmFromPrivateKey(s.Key, s.Alg),
7✔
230
                        Key:       s.Key,
7✔
231
                }
7✔
232

7✔
233
                initFns := []rfc.SignerOption{
7✔
234
                        rfc.WithTTL(sigValidDuration),
7✔
235
                }
7✔
236

7✔
237
                if s.tag != "" {
7✔
238
                        initFns = append(initFns, rfc.WithTag(s.tag))
×
239
                }
×
240

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

269
var ErrRetry = errors.Newf("retry")
270

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

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

16✔
314
                res, err := tr.RoundTrip(req)
16✔
315
                if lastTry || err != nil {
18✔
316
                        return res, err
2✔
317
                }
2✔
318

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

5✔
340
                        return nil, ErrRetry
5✔
341
                default:
9✔
342
                        // NOTE(marius): some kind of success
9✔
343
                        return res, nil
9✔
344
                }
345
        }
346

347
        or := *req
12✔
348

12✔
349
        if or.URL != nil && or.URL.Path == "" {
24✔
350
                or.URL.Path = "/"
12✔
351
        }
12✔
352

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

377
                // NOTE(marius): try #2: use Cavage-12 draft signature
378
                lctx["tries"] = 1
7✔
379
                lctx["sig-alg"] = "draft"
7✔
380
                res, err = roundTripFn(cloneRequest(&or, buff), s.signRequestDraft, s.l.WithContext(lctx))
7✔
381
                if err == nil || !errors.Is(err, ErrRetry) {
12✔
382
                        return res, err
5✔
383
                }
5✔
384

385
                // NOTE(marius): if draft signatures failed also, and this is not
386
                // a request that we can retry w/o a signature, we return the resulting
387
                // errors.
388
                if err != nil && errors.Is(err, ErrRetry) && !isFetchRequest {
3✔
389
                        return res, errors.Unauthorizedf("unauthorized")
1✔
390
                }
1✔
391

392
                if res != nil && res.Body != nil {
1✔
393
                        _ = res.Body.Close()
×
394
                }
×
395
        }
396

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

407
        return nil, errors.Unauthorizedf("unauthorized")
×
408
}
409

410
type KeyEncoding int
411

412
const (
413
        KeyTypeUnknown KeyEncoding = 0
414
        KeyTypePKCS    KeyEncoding = 1
415
        KeyTypePSS     KeyEncoding = 2
416
)
417

418
func toCryptoPublicKey(key vocab.PublicKey) (crypto.PublicKey, error) {
7✔
419
        pubBytes, _ := pem.Decode([]byte(key.PublicKeyPem))
7✔
420
        if pubBytes == nil {
7✔
421
                return nil, errors.Newf("unable to decode PEM payload for public key")
×
422
        }
×
423
        pk, _ := x509.ParsePKIXPublicKey(pubBytes.Bytes)
7✔
424
        if pk != nil {
13✔
425
                return pk, nil
6✔
426
        }
6✔
427
        pk, err := x509.ParsePKCS1PublicKey(pubBytes.Bytes)
1✔
428
        return pk, err
1✔
429
}
430

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

435
func (s *Transport) signRequestDraft(req *http.Request) error {
7✔
436
        if s.Actor == nil {
7✔
437
                return errors.Newf("unable to sign request, Actor is invalid")
×
438
        }
×
439
        if s.Key == nil {
7✔
440
                return errors.Newf("unable to sign request, private key is invalid")
×
441
        }
×
442
        if !s.Actor.PublicKey.ID.IsValid() {
7✔
443
                return errors.Newf("unable to sign request, invalid Actor public key ID")
×
444
        }
×
445

446
        keyID := s.Actor.PublicKey.ID
7✔
447

7✔
448
        headers := HeadersToSign
7✔
449
        bodyBuf := bytes.Buffer{}
7✔
450
        if req.Body != nil {
14✔
451
                if _, err := io.Copy(&bodyBuf, req.Body); err == nil {
14✔
452
                        req.Body = io.NopCloser(&bodyBuf)
7✔
453
                        if bodyBuf.Len() > 0 {
12✔
454
                                headers = append(HeadersToSign, "digest")
5✔
455
                        }
5✔
456
                }
457
        }
458

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

485
        secToExpiration := int64(sigValidDuration.Seconds())
7✔
486
        // NOTE(marius): The only http-signatures accepted by Mastodon instances is "Signature", not "Authorization"
7✔
487
        sig, _, err := draft.NewSigner(algos, digestAlgorithm, headers, draft.Signature, secToExpiration)
7✔
488
        if err != nil {
7✔
489
                return err
×
490
        }
×
491
        return sig.SignRequest(s.Key, string(keyID), req, bodyBuf.Bytes())
7✔
492
}
493

494
var _ http.RoundTripper = new(Transport)
495

496
// cloneRequest returns a clone of the provided *http.Request.
497
// The clone is a shallow copy of the struct and its Header map.
498
func cloneRequest(r *http.Request, buff []byte) *http.Request {
14✔
499
        r2 := r.Clone(r.Context())
14✔
500
        if buff != nil {
28✔
501
                r2.Body = io.NopCloser(bytes.NewReader(buff))
14✔
502
        }
14✔
503
        return r2
14✔
504
}
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